diff --git a/internal/repository/warehouse.go b/internal/repository/warehouse.go index cbc29a4f..58a74b44 100644 --- a/internal/repository/warehouse.go +++ b/internal/repository/warehouse.go @@ -2,11 +2,17 @@ package repository import ( "database/sql" + "errors" "github.com/wmms/wmms/internal/model" "github.com/wmms/wmms/pkg/database" ) +// ErrWarehouseNotFound 表示仓库 id 不存在,或这条仓库不属于当前组织。 +// 建库位时的 INSERT...SELECT 影响 0 行就是这种情况,接口据此回错, +// 而不是把「没写成」报成"创建成功"。 +var ErrWarehouseNotFound = errors.New("仓库不存在,可能已被删除或不属于当前组织") + type WarehouseRepository struct{} func NewWarehouseRepository() *WarehouseRepository { return &WarehouseRepository{} } @@ -58,11 +64,25 @@ func (r *WarehouseRepository) Update(item *model.Warehouse) error { } func (r *WarehouseRepository) CreateLocation(item *model.WarehouseLocation, organizationID int64) error { + // 仓库归属条件写在 SELECT 里,避免「先查后插」之间的竞态。 + // + // 匹配不到仓库时这条语句影响 0 行,但 MySQL 不报错:err 为 nil,LastInsertId() + // 返回 0。此前只看 err,接口回 201「创建成功」而库里一行都没有(实测请求前后 + // warehouse_locations 行数都是 1,刷新后库位消失)。所以这里和按主键更新一样, + // 以 RowsAffected 为准——INSERT 的 RowsAffected 就是实际插入行数,不受 + // clientFoundRows 影响。 result, err := database.DB.Exec(`INSERT INTO warehouse_locations (warehouse_id, code, name, status) SELECT id, ?, ?, ? FROM warehouses WHERE id = ? AND organization_id = ?`, item.Code, item.Name, item.Status, item.WarehouseID, organizationID) if err != nil { return err } + affected, err := result.RowsAffected() + if err != nil { + return err + } + if affected == 0 { + return ErrWarehouseNotFound + } item.ID, err = result.LastInsertId() return err } diff --git a/internal/repository/warehouse_test.go b/internal/repository/warehouse_test.go new file mode 100644 index 00000000..0dd97aa6 --- /dev/null +++ b/internal/repository/warehouse_test.go @@ -0,0 +1,68 @@ +package repository + +import ( + "errors" + "regexp" + "testing" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/wmms/wmms/internal/model" + "github.com/wmms/wmms/pkg/database" +) + +// 与 warehouse.go 里的语句逐字一致:换行和缩进都参与匹配。 +const createLocationSQL = `INSERT INTO warehouse_locations (warehouse_id, code, name, status) + SELECT id, ?, ?, ? FROM warehouses WHERE id = ? AND organization_id = ?` + +func newWarehouseMock(t *testing.T) sqlmock.Sqlmock { + t.Helper() + 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 + return mock +} + +// 仓库不存在时(id 打错、仓库已被删、或这条仓库属于别的组织)这条 INSERT...SELECT +// 影响 0 行,而 MySQL 既不报错、LastInsertId() 也只回 0。此前只看 err,接口会回 +// 201「Location created successfully」,库里却没有新行,刷新后库位消失、重试还是 +// "成功"(实测请求前后 warehouse_locations 行数都是 1)。 +func TestCreateLocationReportsMissingWarehouse(t *testing.T) { + mock := newWarehouseMock(t) + mock.ExpectExec(regexp.QuoteMeta(createLocationSQL)). + WithArgs("L1", "库位一", "active", int64(999), int64(1)). + WillReturnResult(sqlmock.NewResult(0, 0)) + + item := &model.WarehouseLocation{WarehouseID: 999, Code: "L1", Name: "库位一", Status: "active"} + err := NewWarehouseRepository().CreateLocation(item, 1) + if !errors.Is(err, ErrWarehouseNotFound) { + t.Fatalf("影响 0 行应当返回 ErrWarehouseNotFound,实际 %v", err) + } + if item.ID != 0 { + t.Fatalf("没插进去就不该有 id,实际 %d", item.ID) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } +} + +// 反面:真的插进去了就必须回填 id,别把成功路径也一起判成失败。 +func TestCreateLocationReturnsInsertedID(t *testing.T) { + mock := newWarehouseMock(t) + mock.ExpectExec(regexp.QuoteMeta(createLocationSQL)). + WithArgs("L1", "库位一", "active", int64(3), int64(1)). + WillReturnResult(sqlmock.NewResult(7, 1)) + + item := &model.WarehouseLocation{WarehouseID: 3, Code: "L1", Name: "库位一", Status: "active"} + if err := NewWarehouseRepository().CreateLocation(item, 1); err != nil { + t.Fatalf("插入成功不应报错,实际 %v", err) + } + if item.ID != 7 { + t.Fatalf("应当回填自增 id 7,实际 %d", item.ID) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatal(err) + } +}