package repository import ( "fmt" "regexp" "strings" "testing" "github.com/DATA-DOG/go-sqlmock" "github.com/wmms/wmms/internal/model" "github.com/wmms/wmms/pkg/database" ) const importLockRuleQuery = `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` const insertMaterialBatchStmt = `INSERT INTO materials (organization_id, material_code, name, material_image, spec, manufacturer_model, category_id, base_unit_id, unit, status, version) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?), (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` func newImportMock(t *testing.T) (sqlmock.Sqlmock, func()) { t.Helper() db, mock, err := sqlmock.New() if err != nil { t.Fatalf("open sql mock: %v", err) } previous := database.DB database.DB = db return mock, func() { database.DB = previous db.Close() } } func materialCategoryPtr(id int64) *int64 { return &id } func TestMaterialRepoImportTxAllocatesCodesAndInsertsOneByOne(t *testing.T) { mock, cleanup := newImportMock(t) defer cleanup() mock.ExpectBegin() mock.ExpectQuery(regexp.QuoteMeta(importLockRuleQuery)).WithArgs(int64(1), int64(12)). WillReturnRows(sqlmock.NewRows([]string{"id", "organization_id", "category_id", "prefix", "sequence_length", "next_sequence"}). AddRow(5, 1, 12, "A01", 4, 201)) // 已占用编码探测:A010201 已被手工录入占用,应跳到 A010202 mock.ExpectQuery(regexp.QuoteMeta(`SELECT material_code FROM materials WHERE organization_id = ? AND material_code LIKE ?`)). WithArgs(int64(1), "A01%"). WillReturnRows(sqlmock.NewRows([]string{"material_code"}).AddRow("A010201")) mock.ExpectExec(regexp.QuoteMeta(`UPDATE code_rules SET next_sequence = ? WHERE id = ?`)). WithArgs(int64(204), int64(5)).WillReturnResult(sqlmock.NewResult(0, 1)) // 两行折叠成一条多值 INSERT mock.ExpectExec(regexp.QuoteMeta(insertMaterialBatchStmt)). WithArgs( int64(1), "A010202", "螺栓", nil, "", nil, int64(12), nil, "个", "active", 1, int64(1), "A010203", "螺母", nil, "", nil, int64(12), nil, "个", "active", 1, ). WillReturnResult(sqlmock.NewResult(31, 2)) mock.ExpectQuery(regexp.QuoteMeta(`SELECT id, material_code FROM materials WHERE organization_id = ? AND material_code IN (?,?)`)). WithArgs(int64(1), "A010202", "A010203"). WillReturnRows(sqlmock.NewRows([]string{"id", "material_code"}). AddRow(31, "A010202").AddRow(32, "A010203")) tx, err := database.DB.Begin() if err != nil { t.Fatalf("begin: %v", err) } categoryID := int64(12) materials := []*model.Material{ {OrganizationID: 1, Name: "螺栓", CategoryID: &categoryID, Unit: "个", Status: "active", Version: 1}, {OrganizationID: 1, Name: "螺母", CategoryID: &categoryID, Unit: "个", Status: "active", Version: 1}, } if err := NewMaterialRepo().ImportTx(tx, 1, materials); err != nil { t.Fatalf("import: %v", err) } if materials[0].MaterialCode != "A010202" || materials[0].ID != 31 { t.Fatalf("first = %+v", materials[0]) } if materials[1].MaterialCode != "A010203" || materials[1].ID != 32 { t.Fatalf("second = %+v", materials[1]) } if err := mock.ExpectationsWereMet(); err != nil { t.Fatal(err) } } func TestMaterialRepoImportTxReportsMissingCodeRule(t *testing.T) { mock, cleanup := newImportMock(t) defer cleanup() mock.ExpectBegin() mock.ExpectQuery(regexp.QuoteMeta(importLockRuleQuery)).WithArgs(int64(1), int64(12)). WillReturnRows(sqlmock.NewRows([]string{"id", "organization_id", "category_id", "prefix", "sequence_length", "next_sequence"})) tx, err := database.DB.Begin() if err != nil { t.Fatalf("begin: %v", err) } materials := []*model.Material{ {OrganizationID: 1, Name: "螺栓", CategoryID: materialCategoryPtr(12), Unit: "个", Status: "active", Version: 1}, } err = NewMaterialRepo().ImportTx(tx, 1, materials) if err == nil || !strings.Contains(err.Error(), "编码规则") { t.Fatalf("err = %v, want 编码规则错误", err) } if err := mock.ExpectationsWereMet(); err != nil { t.Fatal(err) } } func TestMaterialRepoImportTxBatchesInsertsOverFiveHundredRows(t *testing.T) { // 501 行 → 两批插入(500 + 1)。插入语句与回查参数很长,这里用子串松匹配, // 只断言"分了两批"以及编码序号按批连续。 mock, cleanup := newImportMock(t) defer cleanup() mock.ExpectBegin() mock.ExpectQuery(regexp.QuoteMeta(importLockRuleQuery)).WithArgs(int64(1), int64(12)). WillReturnRows(sqlmock.NewRows([]string{"id", "organization_id", "category_id", "prefix", "sequence_length", "next_sequence"}). AddRow(5, 1, 12, "A01", 4, 1)) mock.ExpectQuery(`SELECT material_code FROM materials`). WillReturnRows(sqlmock.NewRows([]string{"material_code"})) mock.ExpectExec(`UPDATE code_rules SET next_sequence`).WillReturnResult(sqlmock.NewResult(0, 1)) mock.ExpectExec(`INSERT INTO materials`).WillReturnResult(sqlmock.NewResult(1000, 500)) mock.ExpectQuery(`SELECT id, material_code FROM materials`).WillReturnRows(idRowsFor(1, 500)) mock.ExpectExec(`INSERT INTO materials`).WillReturnResult(sqlmock.NewResult(1500, 1)) mock.ExpectQuery(`SELECT id, material_code FROM materials`).WillReturnRows(idRowsFor(501, 501)) tx, err := database.DB.Begin() if err != nil { t.Fatalf("begin: %v", err) } categoryID := int64(12) materials := make([]*model.Material, 0, 501) for i := 0; i < 501; i++ { materials = append(materials, &model.Material{ OrganizationID: 1, Name: "物料", CategoryID: &categoryID, Unit: "个", Status: "active", Version: 1, }) } if err := NewMaterialRepo().ImportTx(tx, 1, materials); err != nil { t.Fatalf("import: %v", err) } if materials[0].MaterialCode != "A010001" || materials[500].MaterialCode != "A010501" { t.Fatalf("codes = %s ... %s", materials[0].MaterialCode, materials[500].MaterialCode) } if err := mock.ExpectationsWereMet(); err != nil { t.Fatal(err) } } // idRowsFor 造出回查语句的返回行:序号 [from, to] 对应的物料 ID 与编码。 func idRowsFor(from, to int) *sqlmock.Rows { rows := sqlmock.NewRows([]string{"id", "material_code"}) for i := from; i <= to; i++ { rows.AddRow(int64(i), fmt.Sprintf("A01%04d", i)) } return rows } func TestMaterialLogRepoCreateBatchTx(t *testing.T) { mock, cleanup := newImportMock(t) defer cleanup() mock.ExpectBegin() mock.ExpectExec(`INSERT INTO material_logs`).WillReturnResult(sqlmock.NewResult(1, 2)) tx, err := database.DB.Begin() if err != nil { t.Fatalf("begin: %v", err) } logs := []*model.MaterialLog{ {OperationType: "CREATE", MaterialID: 31, Operator: "tester", Description: "批量导入"}, {OperationType: "CREATE", MaterialID: 32, Operator: "tester", Description: "批量导入"}, } if err := NewMaterialLogRepo().CreateBatchTx(tx, logs); err != nil { t.Fatalf("create batch: %v", err) } if err := mock.ExpectationsWereMet(); err != nil { t.Fatal(err) } } func TestMaterialImportLogRepoCreate(t *testing.T) { mock, cleanup := newImportMock(t) defer cleanup() mock.ExpectExec(regexp.QuoteMeta(`INSERT INTO material_import_logs (organization_id, operator, file_name, total_rows, success_rows, failed_rows, status, failure_reason) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`)). WithArgs(int64(1), "tester", "物料.xlsx", 200, 0, 3, "failed", "共 3 行校验未通过"). WillReturnResult(sqlmock.NewResult(9, 1)) log := &model.MaterialImportLog{ OrganizationID: 1, Operator: "tester", FileName: "物料.xlsx", TotalRows: 200, SuccessRows: 0, FailedRows: 3, Status: "failed", FailureReason: "共 3 行校验未通过", } if err := NewMaterialImportLogRepo().Create(log); err != nil { t.Fatalf("create: %v", err) } if log.ID != 9 { t.Fatalf("id = %d, want 9", log.ID) } if err := mock.ExpectationsWereMet(); err != nil { t.Fatal(err) } }