package handler import ( "fmt" "net/http" "net/http/httptest" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/wangjia/jiu/backend/internal/model" "github.com/wangjia/jiu/backend/testutil" ) func TestInventoryHandler_List(t *testing.T) { db := testutil.SetupTestDB() shop := testutil.CreateTestShop(db, "INV001") user := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin") warehouse := testutil.CreateTestWarehouse(db, shop.ID, "Warehouse") product := testutil.CreateTestProduct(db, shop.ID, "Beer") token := getAuthToken(user.ID, shop.ID, "admin") r := setupProtectedRouter(db) // 直接插入库存记录 inv := model.Inventory{ ShopID: shop.ID, WarehouseID: &warehouse.ID, ProductID: &product.ID, Quantity: 100, } require.NoError(t, db.Create(&inv).Error) w := makeRequest(r, "GET", "/api/v1/inventory", token, nil) assert.Equal(t, http.StatusOK, w.Code) resp := parseResponse(w) assert.Equal(t, float64(1), resp["total"].(float64)) } func TestInventoryHandler_List_FilterByWarehouse(t *testing.T) { db := testutil.SetupTestDB() shop := testutil.CreateTestShop(db, "INV002") user := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin") warehouse1 := testutil.CreateTestWarehouse(db, shop.ID, "W1") warehouse2 := testutil.CreateTestWarehouse(db, shop.ID, "W2") product1 := testutil.CreateTestProduct(db, shop.ID, "Beer1") product2 := testutil.CreateTestProduct(db, shop.ID, "Beer2") token := getAuthToken(user.ID, shop.ID, "admin") r := setupProtectedRouter(db) // 两个仓库各有一个库存 db.Create(&model.Inventory{ShopID: shop.ID, WarehouseID: &warehouse1.ID, ProductID: &product1.ID, Quantity: 10}) db.Create(&model.Inventory{ShopID: shop.ID, WarehouseID: &warehouse2.ID, ProductID: &product2.ID, Quantity: 20}) // 按仓库过滤 w := makeRequest(r, "GET", fmt.Sprintf("/api/v1/inventory?warehouse_id=%d", warehouse1.ID), token, nil) assert.Equal(t, http.StatusOK, w.Code) resp := parseResponse(w) assert.Equal(t, float64(1), resp["total"].(float64)) } func TestInventoryHandler_List_FilterByProduct(t *testing.T) { db := testutil.SetupTestDB() shop := testutil.CreateTestShop(db, "INV_PID") user := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin") wh1 := testutil.CreateTestWarehouse(db, shop.ID, "W1") wh2 := testutil.CreateTestWarehouse(db, shop.ID, "W2") product1 := testutil.CreateTestProduct(db, shop.ID, "Beer1") product2 := testutil.CreateTestProduct(db, shop.ID, "Beer2") token := getAuthToken(user.ID, shop.ID, "admin") r := setupProtectedRouter(db) // product1 在两个仓库各有库存;product2 一条 db.Create(&model.Inventory{ShopID: shop.ID, WarehouseID: &wh1.ID, ProductID: &product1.ID, Quantity: 10}) db.Create(&model.Inventory{ShopID: shop.ID, WarehouseID: &wh2.ID, ProductID: &product1.ID, Quantity: 20}) db.Create(&model.Inventory{ShopID: shop.ID, WarehouseID: &wh1.ID, ProductID: &product2.ID, Quantity: 5}) // 按商品过滤 → 只返回 product1 的两条(商品详情:各仓库分布) w := makeRequest(r, "GET", fmt.Sprintf("/api/v1/inventory?product_id=%d&page_size=100", product1.ID), token, nil) assert.Equal(t, http.StatusOK, w.Code) assert.Equal(t, float64(2), parseResponse(w)["total"].(float64)) } func TestInventoryHandler_List_InStockOnly(t *testing.T) { db := testutil.SetupTestDB() shop := testutil.CreateTestShop(db, "INV003") user := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin") warehouse := testutil.CreateTestWarehouse(db, shop.ID, "Warehouse") product1 := testutil.CreateTestProduct(db, shop.ID, "InStock") product2 := testutil.CreateTestProduct(db, shop.ID, "OutOfStock") token := getAuthToken(user.ID, shop.ID, "admin") r := setupProtectedRouter(db) db.Create(&model.Inventory{ShopID: shop.ID, WarehouseID: &warehouse.ID, ProductID: &product1.ID, Quantity: 10}) db.Create(&model.Inventory{ShopID: shop.ID, WarehouseID: &warehouse.ID, ProductID: &product2.ID, Quantity: 0}) // 只显示有库存的 w := makeRequest(r, "GET", "/api/v1/inventory?in_stock=1", token, nil) assert.Equal(t, http.StatusOK, w.Code) resp := parseResponse(w) assert.Equal(t, float64(1), resp["total"].(float64)) } func TestInventoryHandler_Logs(t *testing.T) { db := testutil.SetupTestDB() shop := testutil.CreateTestShop(db, "INV004") user := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin") warehouse := testutil.CreateTestWarehouse(db, shop.ID, "Warehouse") product := testutil.CreateTestProduct(db, shop.ID, "Wine") token := getAuthToken(user.ID, shop.ID, "admin") r := setupProtectedRouter(db) // 创建库存流水 opID := user.ID db.Create(&model.InventoryLog{ ShopID: shop.ID, WarehouseID: warehouse.ID, ProductID: product.ID, Direction: "in", Quantity: 10, QtyBefore: 0, QtyAfter: 10, RefType: "stock_in", RefID: 1, OperatorID: &opID, }) w := makeRequest(r, "GET", "/api/v1/inventory/logs", token, nil) assert.Equal(t, http.StatusOK, w.Code) resp := parseResponse(w) assert.Equal(t, float64(1), resp["total"].(float64)) } func TestInventoryHandler_CreateCheck(t *testing.T) { db := testutil.SetupTestDB() shop := testutil.CreateTestShop(db, "INV005") user := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin") warehouse := testutil.CreateTestWarehouse(db, shop.ID, "Warehouse") product := testutil.CreateTestProduct(db, shop.ID, "Whiskey") token := getAuthToken(user.ID, shop.ID, "admin") r := setupProtectedRouter(db) // 先创建库存 db.Create(&model.Inventory{ ShopID: shop.ID, WarehouseID: &warehouse.ID, ProductID: &product.ID, Quantity: 50, }) // 创建盘点单 w := makeRequest(r, "POST", "/api/v1/inventory/checks", token, map[string]interface{}{ "check_no": "CHK001", "warehouse_id": warehouse.ID, "check_date": time.Now().Format(time.RFC3339), "items": []map[string]interface{}{ { "product_id": product.ID, "actual_qty": 48.0, }, }, }) require.Equal(t, http.StatusCreated, w.Code) checkID := extractID(w) assert.NotZero(t, checkID) data := parseResponse(w)["data"].(map[string]interface{}) assert.Equal(t, "draft", data["status"]) // 检查 system_qty 是否自动填入 items := data["items"].([]interface{}) require.Len(t, items, 1) item := items[0].(map[string]interface{}) assert.Equal(t, float64(50), item["system_qty"]) } func TestInventoryHandler_GetCheck(t *testing.T) { db := testutil.SetupTestDB() shop := testutil.CreateTestShop(db, "INV006") user := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin") warehouse := testutil.CreateTestWarehouse(db, shop.ID, "Warehouse") product := testutil.CreateTestProduct(db, shop.ID, "Vodka") token := getAuthToken(user.ID, shop.ID, "admin") r := setupProtectedRouter(db) // 创建盘点单 w := makeRequest(r, "POST", "/api/v1/inventory/checks", token, map[string]interface{}{ "check_no": "CHK002", "warehouse_id": warehouse.ID, "check_date": time.Now().Format(time.RFC3339), "items": []map[string]interface{}{ {"product_id": product.ID, "actual_qty": 10.0}, }, }) checkID := extractID(w) // 获取盘点单 w = makeRequest(r, "GET", fmt.Sprintf("/api/v1/inventory/checks/%d", checkID), token, nil) assert.Equal(t, http.StatusOK, w.Code) data := parseResponse(w)["data"].(map[string]interface{}) assert.Equal(t, "CHK002", data["check_no"]) } func TestInventoryHandler_GetCheck_NotFound(t *testing.T) { db := testutil.SetupTestDB() shop := testutil.CreateTestShop(db, "INV007") user := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin") token := getAuthToken(user.ID, shop.ID, "admin") r := setupProtectedRouter(db) w := makeRequest(r, "GET", "/api/v1/inventory/checks/99999", token, nil) assert.Equal(t, http.StatusNotFound, w.Code) } func TestInventoryHandler_List_HotelIsolation(t *testing.T) { db := testutil.SetupTestDB() shopA := testutil.CreateTestShop(db, "INV_A") userA := testutil.CreateTestUser(db, shopA.ID, "adminA", "pass", "admin") warehouseA := testutil.CreateTestWarehouse(db, shopA.ID, "WA") productA := testutil.CreateTestProduct(db, shopA.ID, "ProductA") tokenA := getAuthToken(userA.ID, shopA.ID, "admin") shopB := testutil.CreateTestShop(db, "INV_B") userB := testutil.CreateTestUser(db, shopB.ID, "adminB", "pass", "admin") tokenB := getAuthToken(userB.ID, shopB.ID, "admin") r := setupProtectedRouter(db) // 酒店 A 有库存 db.Create(&model.Inventory{ShopID: shopA.ID, WarehouseID: &warehouseA.ID, ProductID: &productA.ID, Quantity: 100}) // 酒店 A 能看到自己的库存 w := makeRequest(r, "GET", "/api/v1/inventory", tokenA, nil) respA := parseResponse(w) assert.Equal(t, float64(1), respA["total"].(float64)) // 酒店 B 看不到酒店 A 的库存 w = makeRequest(r, "GET", "/api/v1/inventory", tokenB, nil) respB := parseResponse(w) assert.Equal(t, float64(0), respB["total"].(float64)) } // 库存指向已软删的占位商品时,列表应回退到明细/库存快照真名, // 而非占位商品名「历史导入占位」,且能按真名搜索到。 func TestInventoryHandler_List_DeletedProductFallsBackToSnapshot(t *testing.T) { db := testutil.SetupTestDB() shop := testutil.CreateTestShop(db, "INV009") user := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin") warehouse := testutil.CreateTestWarehouse(db, shop.ID, "Warehouse") placeholder := testutil.CreateTestProduct(db, shop.ID, "历史导入占位") token := getAuthToken(user.ID, shop.ID, "admin") r := setupProtectedRouter(db) // 明细携带真实快照名 item := model.StockInItem{ ShopID: shop.ID, ProductID: placeholder.ID, ProductCode: "ZXZ029751", ProductName: "贵州茅台酒2006", Quantity: 10, } require.NoError(t, db.Create(&item).Error) // 库存:product_id 指向占位商品,且 product_name 也是占位名(模拟旧脏数据) require.NoError(t, db.Create(&model.Inventory{ ShopID: shop.ID, WarehouseID: &warehouse.ID, ProductID: &placeholder.ID, StockInItemID: &item.ID, ProductName: "历史导入占位", Quantity: 10, }).Error) // 软删占位商品 require.NoError(t, db.Delete(&placeholder).Error) // 列表:名称应回退到明细快照真名 w := makeRequest(r, "GET", "/api/v1/inventory", token, nil) resp := parseResponse(w) require.Equal(t, float64(1), resp["total"].(float64)) invItem := resp["data"].([]interface{})[0].(map[string]interface{}) assert.Equal(t, "贵州茅台酒2006", invItem["product_name"]) // 按真名可搜到 w = makeRequest(r, "GET", "/api/v1/inventory?keyword=茅台", token, nil) assert.Equal(t, float64(1), parseResponse(w)["total"].(float64)) } // 商品编码独立筛选(?code=,库存屏第二个搜索框) func TestInventoryHandler_List_FilterByCode(t *testing.T) { db := testutil.SetupTestDB() shop := testutil.CreateTestShop(db, "INV_CODE") user := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin") warehouse := testutil.CreateTestWarehouse(db, shop.ID, "Warehouse") product1 := testutil.CreateTestProduct(db, shop.ID, "Beer1") // code=P-Beer1 product2 := testutil.CreateTestProduct(db, shop.ID, "Wine2") // code=P-Wine2 token := getAuthToken(user.ID, shop.ID, "admin") r := setupProtectedRouter(db) db.Create(&model.Inventory{ShopID: shop.ID, WarehouseID: &warehouse.ID, ProductID: &product1.ID, Quantity: 10}) db.Create(&model.Inventory{ShopID: shop.ID, WarehouseID: &warehouse.ID, ProductID: &product2.ID, Quantity: 20}) // 精确前缀命中一条 w := makeRequest(r, "GET", "/api/v1/inventory?code=P-Beer1", token, nil) assert.Equal(t, http.StatusOK, w.Code) assert.Equal(t, float64(1), parseResponse(w)["total"].(float64)) // 模糊命中两条 w = makeRequest(r, "GET", "/api/v1/inventory?code=P-", token, nil) assert.Equal(t, float64(2), parseResponse(w)["total"].(float64)) // 无命中 w = makeRequest(r, "GET", "/api/v1/inventory?code=NOPE", token, nil) assert.Equal(t, float64(0), parseResponse(w)["total"].(float64)) } // Summary 月初快照字段:月初数量 = 当前数量 − 本月净流水 func TestInventoryHandler_Summary_LastMonth(t *testing.T) { db := testutil.SetupTestDB() shop := testutil.CreateTestShop(db, "INV_SUM") user := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin") warehouse := testutil.CreateTestWarehouse(db, shop.ID, "Warehouse") product1 := testutil.CreateTestProduct(db, shop.ID, "Old") product2 := testutil.CreateTestProduct(db, shop.ID, "New") token := getAuthToken(user.ID, shop.ID, "admin") r := setupProtectedRouter(db) now := time.Now() monthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location()) price1 := 100.0 // inv1:上月已存在(月初数量 = 30 − (25−5) = 10) inv1 := model.Inventory{ ShopID: shop.ID, WarehouseID: &warehouse.ID, ProductID: &product1.ID, Quantity: 30, UnitPrice: &price1, CreatedAt: monthStart.AddDate(0, 0, -10), } require.NoError(t, db.Create(&inv1).Error) // 本月流水:入 25、出 5 → 净 +20 db.Create(&model.InventoryLog{ShopID: shop.ID, WarehouseID: warehouse.ID, ProductID: product1.ID, Direction: "in", Quantity: 25}) db.Create(&model.InventoryLog{ShopID: shop.ID, WarehouseID: warehouse.ID, ProductID: product1.ID, Direction: "out", Quantity: 5}) // inv2:本月新入库,不计入月初快照 price2 := 10.0 db.Create(&model.Inventory{ ShopID: shop.ID, WarehouseID: &warehouse.ID, ProductID: &product2.ID, Quantity: 5, UnitPrice: &price2, }) db.Create(&model.InventoryLog{ShopID: shop.ID, WarehouseID: warehouse.ID, ProductID: product2.ID, Direction: "in", Quantity: 5}) w := makeRequest(r, "GET", "/api/v1/inventory/summary", token, nil) assert.Equal(t, http.StatusOK, w.Code) resp := parseResponse(w) assert.Equal(t, float64(2), resp["sku_count"].(float64)) assert.Equal(t, float64(35), resp["in_stock_qty"].(float64)) assert.Equal(t, float64(30*100+5*10), resp["stock_value"].(float64)) assert.Equal(t, float64(1), resp["last_month_sku"].(float64)) assert.Equal(t, float64(10), resp["last_month_qty"].(float64)) assert.Equal(t, float64(10*100), resp["last_month_value"].(float64)) } func TestInventoryHandler_AfterStockInApprove(t *testing.T) { db := testutil.SetupTestDB() shop := testutil.CreateTestShop(db, "INV008") user := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin") warehouse := testutil.CreateTestWarehouse(db, shop.ID, "Warehouse") product := testutil.CreateTestProduct(db, shop.ID, "Champagne") token := getAuthToken(user.ID, shop.ID, "admin") r := setupProtectedRouter(db) // 初始库存为 0 w := makeRequest(r, "GET", "/api/v1/inventory", token, nil) assert.Equal(t, float64(0), parseResponse(w)["total"].(float64)) // 入库流程 w = makeRequest(r, "POST", "/api/v1/stock-in/orders", token, map[string]interface{}{ "warehouse_id": warehouse.ID, "order_date": time.Now().Format(time.RFC3339), "items": []map[string]interface{}{ {"product_id": product.ID, "quantity": 20.0, "unit_price": 15.0}, }, }) orderID := extractID(w) makeRequest(r, "PUT", fmt.Sprintf("/api/v1/stock-in/orders/%d/submit", orderID), token, nil) makeRequest(r, "PUT", fmt.Sprintf("/api/v1/stock-in/orders/%d/approve", orderID), token, nil) // 检查库存 w = makeRequest(r, "GET", "/api/v1/inventory", token, nil) resp := parseResponse(w) assert.Equal(t, float64(1), resp["total"].(float64)) data := resp["data"].([]interface{}) invItem := data[0].(map[string]interface{}) assert.Equal(t, float64(20), invItem["quantity"]) } // 库存成本仅管理员可见(stripInventoryCost):operator/readonly 的 List unit_price 抹空、 // Summary 货值抹零;admin 原样返回。与出库 stripStockOutCost 同口径。 func TestInventoryHandler_CostVisibility(t *testing.T) { db := testutil.SetupTestDB() shop := testutil.CreateTestShop(db, "INV_COST") admin := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin") operator := testutil.CreateTestUser(db, shop.ID, "op", "pass", "operator") readonly := testutil.CreateTestUser(db, shop.ID, "ro", "pass", "readonly") warehouse := testutil.CreateTestWarehouse(db, shop.ID, "Warehouse") product := testutil.CreateTestProduct(db, shop.ID, "Beer") r := setupProtectedRouter(db) price := 100.0 require.NoError(t, db.Create(&model.Inventory{ ShopID: shop.ID, WarehouseID: &warehouse.ID, ProductID: &product.ID, Quantity: 10, UnitPrice: &price, }).Error) // admin:unit_price / stock_value 原样可见 adminToken := getAuthToken(admin.ID, shop.ID, "admin") w := makeRequest(r, "GET", "/api/v1/inventory", adminToken, nil) assert.Equal(t, http.StatusOK, w.Code) row := parseResponse(w)["data"].([]interface{})[0].(map[string]interface{}) assert.Equal(t, float64(100), row["unit_price"]) w = makeRequest(r, "GET", "/api/v1/inventory/summary", adminToken, nil) assert.Equal(t, float64(1000), parseResponse(w)["stock_value"].(float64)) // operator / readonly:unit_price 抹空、货值抹零,其余字段不受影响 for _, u := range []struct { id uint64 role string }{{operator.ID, "operator"}, {readonly.ID, "readonly"}} { token := getAuthToken(u.id, shop.ID, u.role) w = makeRequest(r, "GET", "/api/v1/inventory", token, nil) assert.Equal(t, http.StatusOK, w.Code) row = parseResponse(w)["data"].([]interface{})[0].(map[string]interface{}) assert.Nil(t, row["unit_price"], "role=%s unit_price 应被抹空", u.role) assert.Equal(t, float64(10), row["quantity"]) w = makeRequest(r, "GET", "/api/v1/inventory/summary", token, nil) resp := parseResponse(w) assert.Equal(t, float64(0), resp["stock_value"].(float64), "role=%s 货值应抹零", u.role) assert.Equal(t, float64(0), resp["last_month_value"].(float64)) assert.Equal(t, float64(1), resp["sku_count"].(float64)) assert.Equal(t, float64(10), resp["in_stock_qty"].(float64)) } } // 库存三态(2026-07-07):List 默认排除已售、status 筛选、切换接口、Summary sold_count。 func TestInventoryHandler_StatusFlow(t *testing.T) { db := testutil.SetupTestDB() shop := testutil.CreateTestShop(db, "INV_ST") user := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin") wh := testutil.CreateTestWarehouse(db, shop.ID, "仓") p1 := testutil.CreateTestProduct(db, shop.ID, "库存酒") p2 := testutil.CreateTestProduct(db, shop.ID, "在售酒") p3 := testutil.CreateTestProduct(db, shop.ID, "已售酒") token := getAuthToken(user.ID, shop.ID, "admin") r := setupProtectedRouter(db) price := 50.0 invStock := model.Inventory{ShopID: shop.ID, WarehouseID: &wh.ID, ProductID: &p1.ID, Quantity: 3, UnitPrice: &price, Status: "stock"} invSale := model.Inventory{ShopID: shop.ID, WarehouseID: &wh.ID, ProductID: &p2.ID, Quantity: 2, UnitPrice: &price, Status: "on_sale"} invSold := model.Inventory{ShopID: shop.ID, WarehouseID: &wh.ID, ProductID: &p3.ID, Quantity: 0, UnitPrice: &price, Status: "sold"} require.NoError(t, db.Create(&invStock).Error) require.NoError(t, db.Create(&invSale).Error) require.NoError(t, db.Create(&invSold).Error) // List 默认:不含已售(2 行) w := makeRequest(r, "GET", "/api/v1/inventory", token, nil) assert.Equal(t, float64(2), parseResponse(w)["total"].(float64)) // status=sold:只看已售 w = makeRequest(r, "GET", "/api/v1/inventory?status=sold", token, nil) resp := parseResponse(w) assert.Equal(t, float64(1), resp["total"].(float64)) row := resp["data"].([]interface{})[0].(map[string]interface{}) assert.Equal(t, "sold", row["status"]) // status 多值 w = makeRequest(r, "GET", "/api/v1/inventory?status=stock,on_sale,sold", token, nil) assert.Equal(t, float64(3), parseResponse(w)["total"].(float64)) // 切换:stock → on_sale w = makeRequest(r, "PUT", fmt.Sprintf("/api/v1/inventory/%d/status", invStock.ID), token, map[string]interface{}{"status": "on_sale"}) assert.Equal(t, http.StatusOK, w.Code) var got model.Inventory require.NoError(t, db.Where("id = ?", invStock.ID).First(&got).Error) assert.Equal(t, "on_sale", got.Status) // 拒收 sold 值 w = makeRequest(r, "PUT", fmt.Sprintf("/api/v1/inventory/%d/status", invSale.ID), token, map[string]interface{}{"status": "sold"}) assert.Equal(t, http.StatusBadRequest, w.Code) // 已售行不可改 w = makeRequest(r, "PUT", fmt.Sprintf("/api/v1/inventory/%d/status", invSold.ID), token, map[string]interface{}{"status": "stock"}) assert.Equal(t, http.StatusNotFound, w.Code) // Summary:sku_count 排除已售、sold_count=1、货值只算未售 w = makeRequest(r, "GET", "/api/v1/inventory/summary", token, nil) sm := parseResponse(w) assert.Equal(t, float64(2), sm["sku_count"].(float64)) assert.Equal(t, float64(1), sm["sold_count"].(float64)) assert.Equal(t, float64(3*50+2*50), sm["stock_value"].(float64)) assert.Equal(t, float64(0), sm["shortage_count"].(float64), "sold 不计入缺货") } // 跨租户:不能改他店库存状态 func TestInventoryHandler_StatusTenantIsolation(t *testing.T) { db := testutil.SetupTestDB() shopA := testutil.CreateTestShop(db, "INV_STA") shopB := testutil.CreateTestShop(db, "INV_STB") userA := testutil.CreateTestUser(db, shopA.ID, "admin", "pass", "admin") whB := testutil.CreateTestWarehouse(db, shopB.ID, "仓B") pB := testutil.CreateTestProduct(db, shopB.ID, "他店酒") invB := model.Inventory{ShopID: shopB.ID, WarehouseID: &whB.ID, ProductID: &pB.ID, Quantity: 1, Status: "stock"} require.NoError(t, db.Create(&invB).Error) r := setupProtectedRouter(db) tokenA := getAuthToken(userA.ID, shopA.ID, "admin") w := makeRequest(r, "PUT", fmt.Sprintf("/api/v1/inventory/%d/status", invB.ID), tokenA, map[string]interface{}{"status": "on_sale"}) assert.Equal(t, http.StatusNotFound, w.Code) var got model.Inventory require.NoError(t, db.Where("id = ?", invB.ID).First(&got).Error) assert.Equal(t, "stock", got.Status) } // 列头排序(2026-07-07):白名单列全局排序,非法列忽略回默认 func TestInventoryHandler_ListSort(t *testing.T) { db := testutil.SetupTestDB() shop := testutil.CreateTestShop(db, "INV_SORT") user := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin") wh := testutil.CreateTestWarehouse(db, shop.ID, "仓") token := getAuthToken(user.ID, shop.ID, "admin") r := setupProtectedRouter(db) for i, qty := range []float64{5, 20, 10} { p := testutil.CreateTestProduct(db, shop.ID, fmt.Sprintf("酒%d", i)) price := float64(100 * (i + 1)) require.NoError(t, db.Create(&model.Inventory{ ShopID: shop.ID, WarehouseID: &wh.ID, ProductID: &p.ID, Quantity: qty, UnitPrice: &price, }).Error) } qtys := func(w *httptest.ResponseRecorder) []float64 { rows := parseResponse(w)["data"].([]interface{}) out := make([]float64, len(rows)) for i, r := range rows { out[i] = r.(map[string]interface{})["quantity"].(float64) } return out } // qty 升序 w := makeRequest(r, "GET", "/api/v1/inventory?sort_by=qty&sort_dir=asc", token, nil) assert.Equal(t, []float64{5, 10, 20}, qtys(w)) // qty 降序 w = makeRequest(r, "GET", "/api/v1/inventory?sort_by=qty&sort_dir=desc", token, nil) assert.Equal(t, []float64{20, 10, 5}, qtys(w)) // 总价 = qty×进价:5×100=500, 20×200=4000, 10×300=3000 → 升序 5,10,20 w = makeRequest(r, "GET", "/api/v1/inventory?sort_by=price&sort_dir=asc", token, nil) assert.Equal(t, []float64{5, 10, 20}, qtys(w)) // 非法列忽略 → 默认 id 倒序(最后插入的 qty=10 在前) w = makeRequest(r, "GET", "/api/v1/inventory?sort_by=evil;drop", token, nil) assert.Equal(t, []float64{10, 20, 5}, qtys(w)) }