Files
jiu/backend/internal/handler/product_test.go
T
wangjia 16a56c611d feat(backend): 商品价格历史接口(从入库单价派生)
GET /products/:id/price-history:从已审核入库单明细 unit_price 按 order_date
倒序派生进价变更点,去相邻重复价,守多租户 shop_id(零额外表)。
新增测试 PriceHistory(含草稿排除 + 倒序断言)。go build/vet/test 全过。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TSKEiHsvauyxYUW2itzUXX
2026-07-01 20:46:52 +08:00

308 lines
10 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.ErrDuplicatedKeyCreate/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"])
}