277 lines
9.6 KiB
Go
277 lines
9.6 KiB
Go
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 := `SELECT id, organization_id, material_code, name, material_image, spec, manufacturer_model,
|
||
category_id, base_unit_id, unit, status, version, created_at, updated_at
|
||
FROM materials WHERE id = ?`
|
||
now := time.Date(2026, 9, 20, 9, 0, 0, 0, time.UTC)
|
||
mock.ExpectQuery(regexp.QuoteMeta(query)).WithArgs(int64(7)).WillReturnRows(
|
||
sqlmock.NewRows([]string{"id", "organization_id", "material_code", "name", "material_image", "spec", "manufacturer_model", "category_id", "base_unit_id", "unit", "status", "version", "created_at", "updated_at"}).
|
||
AddRow(7, 1, "MAT-007", "轴承", nil, "6205", nil, 12, 3, "件", "obsolete", 1, now, now),
|
||
)
|
||
|
||
active, err := NewMaterialService().IsActive(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)
|
||
}
|
||
}
|
||
|
||
// 以下常量必须与 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, category_id, base_unit_id, unit, status, version) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||
advanceSequenceStmt = `UPDATE code_rules SET next_sequence = ? WHERE id = ?`
|
||
materialLogStmt = `INSERT INTO material_logs (operation_type, material_id, old_value, new_value, operator, description) VALUES (?, ?, ?, ?, ?, ?)`
|
||
getMaterialQuery = `SELECT id, organization_id, material_code, name, material_image, spec, manufacturer_model,
|
||
category_id, base_unit_id, unit, status, version, created_at, updated_at
|
||
FROM materials WHERE 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, int64(12), nil, "pcs", "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(7)).WillReturnRows(
|
||
sqlmock.NewRows([]string{"id", "organization_id", "material_code", "name", "material_image", "spec", "manufacturer_model", "category_id", "base_unit_id", "unit", "status", "version", "created_at", "updated_at"}).
|
||
AddRow(7, 1, code, "轴承", nil, "", nil, 12, nil, "pcs", "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,
|
||
}, "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, int64(12), nil, "pcs", "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)
|
||
}
|
||
}
|