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) } }