1a1d9aa15e
Deploy Server / release-deploy-server (push) Successful in 53s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YZ4DskSRKsSiheQonFtQvx
269 lines
9.0 KiB
Go
269 lines
9.0 KiB
Go
package handler
|
||
|
||
import (
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"net/http"
|
||
"testing"
|
||
|
||
"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))
|
||
}
|