package handler import ( "encoding/json" "errors" "fmt" "net/http" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "gorm.io/gorm" "github.com/wangjia/jiu/backend/internal/model" "github.com/wangjia/jiu/backend/testutil" ) func TestProductHandler_CRUD(t *testing.T) { db := testutil.SetupTestDB() shop := testutil.CreateTestShop(db, "PROD001") user := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin") token := getAuthToken(user.ID, shop.ID, "admin") r := setupProtectedRouter(db) // 1. Create w := makeRequest(r, "POST", "/api/v1/products", token, map[string]interface{}{ "name": "Test Beer", "code": "BEER001", "unit": "瓶", }) assert.Equal(t, http.StatusCreated, w.Code) productID := extractID(w) assert.NotZero(t, productID) // 2. List w = makeRequest(r, "GET", "/api/v1/products", token, nil) assert.Equal(t, http.StatusOK, w.Code) resp := parseResponse(w) total := resp["total"].(float64) assert.Equal(t, float64(1), total) // 3. Update w = makeRequest(r, "PUT", fmt.Sprintf("/api/v1/products/%d", productID), token, map[string]interface{}{ "name": "Updated Beer", "code": "BEER001", "unit": "瓶", }) assert.Equal(t, http.StatusOK, w.Code) updatedData := parseResponse(w)["data"].(map[string]interface{}) assert.Equal(t, "Updated Beer", updatedData["name"]) // 4. List with keyword w = makeRequest(r, "GET", "/api/v1/products?keyword=Updated", token, nil) assert.Equal(t, http.StatusOK, w.Code) resp = parseResponse(w) assert.Equal(t, float64(1), resp["total"].(float64)) // 5. Delete (soft) w = makeRequest(r, "DELETE", fmt.Sprintf("/api/v1/products/%d", productID), token, nil) assert.Equal(t, http.StatusOK, w.Code) // 6. List after delete - should be 0 w = makeRequest(r, "GET", "/api/v1/products", token, nil) resp = parseResponse(w) assert.Equal(t, float64(0), resp["total"].(float64)) } func TestProductHandler_HotelIsolation(t *testing.T) { db := testutil.SetupTestDB() // 酒店 A shopA := testutil.CreateTestShop(db, "ISOL_A") userA := testutil.CreateTestUser(db, shopA.ID, "adminA", "pass", "admin") tokenA := getAuthToken(userA.ID, shopA.ID, "admin") // 酒店 B shopB := testutil.CreateTestShop(db, "ISOL_B") userB := testutil.CreateTestUser(db, shopB.ID, "adminB", "pass", "admin") tokenB := getAuthToken(userB.ID, shopB.ID, "admin") r := setupProtectedRouter(db) // 酒店 A 创建商品 w := makeRequest(r, "POST", "/api/v1/products", tokenA, map[string]interface{}{ "name": "Hotel A Beer", "unit": "瓶", }) require.Equal(t, http.StatusCreated, w.Code) productAID := extractID(w) // 酒店 B 创建商品 w = makeRequest(r, "POST", "/api/v1/products", tokenB, map[string]interface{}{ "name": "Hotel B Wine", "unit": "瓶", }) require.Equal(t, http.StatusCreated, w.Code) // 酒店 A 只能看到自己的商品 w = makeRequest(r, "GET", "/api/v1/products", tokenA, nil) respA := parseResponse(w) assert.Equal(t, float64(1), respA["total"].(float64)) dataA := respA["data"].([]interface{}) assert.Equal(t, "Hotel A Beer", dataA[0].(map[string]interface{})["name"]) // 酒店 B 只能看到自己的商品 w = makeRequest(r, "GET", "/api/v1/products", tokenB, nil) respB := parseResponse(w) assert.Equal(t, float64(1), respB["total"].(float64)) dataB := respB["data"].([]interface{}) assert.Equal(t, "Hotel B Wine", dataB[0].(map[string]interface{})["name"]) // 酒店 B 不能修改酒店 A 的商品 w = makeRequest(r, "PUT", fmt.Sprintf("/api/v1/products/%d", productAID), tokenB, map[string]interface{}{ "name": "Hacked Product", }) assert.Equal(t, http.StatusNotFound, w.Code) // 酒店 B 不能删除酒店 A 的商品 w = makeRequest(r, "DELETE", fmt.Sprintf("/api/v1/products/%d", productAID), tokenB, nil) assert.Equal(t, http.StatusNotFound, w.Code) } func TestProductHandler_NoAuth(t *testing.T) { db := testutil.SetupTestDB() r := setupProtectedRouter(db) // 无 token 访问 w := makeRequest(r, "GET", "/api/v1/products", "", nil) assert.Equal(t, http.StatusUnauthorized, w.Code) } func TestProductHandler_List_Pagination(t *testing.T) { db := testutil.SetupTestDB() shop := testutil.CreateTestShop(db, "PROD002") user := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin") token := getAuthToken(user.ID, shop.ID, "admin") r := setupProtectedRouter(db) // 创建 5 个商品 for i := 1; i <= 5; i++ { makeRequest(r, "POST", "/api/v1/products", token, map[string]interface{}{ "name": fmt.Sprintf("Product %d", i), "unit": "个", }) } // 每页 2 个,第 1 页 w := makeRequest(r, "GET", "/api/v1/products?page=1&page_size=2", token, nil) assert.Equal(t, http.StatusOK, w.Code) resp := parseResponse(w) data := resp["data"].([]interface{}) assert.Len(t, data, 2) assert.Equal(t, float64(5), resp["total"].(float64)) } func TestProductHandler_UpdateNotFound(t *testing.T) { db := testutil.SetupTestDB() shop := testutil.CreateTestShop(db, "PROD003") user := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin") token := getAuthToken(user.ID, shop.ID, "admin") r := setupProtectedRouter(db) w := makeRequest(r, "PUT", "/api/v1/products/99999", token, map[string]interface{}{ "name": "Nonexistent", }) assert.Equal(t, http.StatusNotFound, w.Code) } func TestProductHandler_DeleteNotFound(t *testing.T) { db := testutil.SetupTestDB() shop := testutil.CreateTestShop(db, "PROD004") user := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin") token := getAuthToken(user.ID, shop.ID, "admin") r := setupProtectedRouter(db) w := makeRequest(r, "DELETE", "/api/v1/products/99999", token, nil) assert.Equal(t, http.StatusNotFound, w.Code) } func TestProductHandler_Create_ShopIDFromToken(t *testing.T) { db := testutil.SetupTestDB() shop := testutil.CreateTestShop(db, "PROD005") user := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin") token := getAuthToken(user.ID, shop.ID, "admin") r := setupProtectedRouter(db) // 尝试在请求体中传入不同的 shop_id w := makeRequest(r, "POST", "/api/v1/products", token, map[string]interface{}{ "name": "Test Product", "shop_id": 9999, // 尝试注入其他门店 ID "unit": "个", }) require.Equal(t, http.StatusCreated, w.Code) resp := parseResponse(w) data := resp["data"].(map[string]interface{}) // shop_id 应该是从 token 中获取的,而不是请求体中的 createdShopID := uint64(data["shop_id"].(float64)) assert.Equal(t, shop.ID, createdShopID) // 反序列化验证 dataBytes, _ := json.Marshal(data) _ = dataBytes } // 自动编码按最大序号递增:P001 → P002 → P003。 func TestProductHandler_AutoCode_Increment(t *testing.T) { db := testutil.SetupTestDB() shop := testutil.CreateTestShop(db, "AC001") user := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin") token := getAuthToken(user.ID, shop.ID, "admin") r := setupProtectedRouter(db) var codes []string for i := 0; i < 3; i++ { w := makeRequest(r, "POST", "/api/v1/products", token, map[string]interface{}{ "name": fmt.Sprintf("AutoP %d", i), "unit": "个", }) require.Equal(t, http.StatusCreated, w.Code) data := parseResponse(w)["data"].(map[string]interface{}) codes = append(codes, data["code"].(string)) } assert.Equal(t, []string{"P001", "P002", "P003"}, codes) } // 软删商品后,新建不复用被删的号(旧 count+1 逻辑会复用 → 重复,此为根因修复回归测试)。 func TestProductHandler_AutoCode_NoReuseAfterSoftDelete(t *testing.T) { db := testutil.SetupTestDB() shop := testutil.CreateTestShop(db, "AC002") user := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin") token := getAuthToken(user.ID, shop.ID, "admin") r := setupProtectedRouter(db) var lastID uint64 for i := 0; i < 3; i++ { // P001 P002 P003 w := makeRequest(r, "POST", "/api/v1/products", token, map[string]interface{}{ "name": fmt.Sprintf("NR %d", i), "unit": "个", }) require.Equal(t, http.StatusCreated, w.Code) lastID = extractID(w) } // 软删 P003 w := makeRequest(r, "DELETE", fmt.Sprintf("/api/v1/products/%d", lastID), token, nil) require.Equal(t, http.StatusOK, w.Code) // 再建 → 必须是 P004,不能复用已软删的 P003 w = makeRequest(r, "POST", "/api/v1/products", token, map[string]interface{}{ "name": "NR new", "unit": "个", }) require.Equal(t, http.StatusCreated, w.Code) data := parseResponse(w)["data"].(map[string]interface{}) assert.Equal(t, "P004", data["code"]) } // (shop_id, code) 唯一约束生效,且重复被翻译成 gorm.ErrDuplicatedKey(Create/FindOrCreate 重试的前提)。 func TestProductHandler_UniqueShopCode(t *testing.T) { db := testutil.SetupTestDB() shop := testutil.CreateTestShop(db, "UQ001") require.NoError(t, db.Exec("CREATE UNIQUE INDEX uk_shop_code ON products(shop_id, code)").Error) p1 := model.Product{TenantBase: model.TenantBase{ShopID: shop.ID}, Name: "A", Code: "P001"} require.NoError(t, db.Create(&p1).Error) p2 := model.Product{TenantBase: model.TenantBase{ShopID: shop.ID}, Name: "B", Code: "P001"} err := db.Create(&p2).Error require.Error(t, err) assert.True(t, errors.Is(err, gorm.ErrDuplicatedKey)) } func TestProductHandler_PriceHistory(t *testing.T) { db := testutil.SetupTestDB() shop := testutil.CreateTestShop(db, "PH001") user := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin") wh := testutil.CreateTestWarehouse(db, shop.ID, "W") product := testutil.CreateTestProduct(db, shop.ID, "Maotai") token := getAuthToken(user.ID, shop.ID, "admin") r := setupProtectedRouter(db) mkOrder := func(no string, daysAgo int, price float64, status string) { o := &model.StockInOrder{ TenantBase: model.TenantBase{ShopID: shop.ID}, OrderNo: no, WarehouseID: wh.ID, OperatorID: user.ID, Status: status, OrderDate: model.Date{Time: time.Now().AddDate(0, 0, -daysAgo)}, } require.NoError(t, db.Create(o).Error) require.NoError(t, db.Create(&model.StockInItem{ OrderID: o.ID, ShopID: shop.ID, ProductID: product.ID, UnitPrice: price, Quantity: 1, }).Error) } mkOrder("RK-PH-1", 60, 2580, "approved") mkOrder("RK-PH-2", 30, 2620, "approved") mkOrder("RK-PH-3", 5, 2680, "approved") mkOrder("RK-PH-4", 1, 9999, "draft") // 草稿不计入 w := makeRequest(r, "GET", fmt.Sprintf("/api/v1/products/%d/price-history", product.ID), token, nil) assert.Equal(t, http.StatusOK, w.Code) data := parseResponse(w)["data"].([]interface{}) assert.Len(t, data, 3) // 三个已审核不同价;草稿排除 // 倒序:最新价在前 assert.Equal(t, float64(2680), data[0].(map[string]interface{})["price"]) }