material_manage_system/internal/service/material_test.go
2026-09-23 14:46:51 +08:00

306 lines
11 KiB
Go
Raw Permalink 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 := `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)
}
}
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", "category_id", "base_unit_id", "unit", "status", "version", "created_at", "updated_at"}).
AddRow(7, 1, "MAT-007", name, nil, "", nil, 12, nil, "pcs", "active", 1, now, now)
}
mock.ExpectQuery(regexp.QuoteMeta(getMaterialQuery)).WithArgs(int64(7)).WillReturnRows(materialRows("原名称"))
mock.ExpectExec(regexp.QuoteMeta("UPDATE materials SET name = ?, category_id = ?, version = version + 1 WHERE id = ?")).
WithArgs("新名称", int64(12), int64(7)).WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectQuery(regexp.QuoteMeta(getMaterialQuery)).WithArgs(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(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, 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)
}
}