test: 后端自动化测试套件

Service 单元测试(SQLite in-memory):
- auth: 登录成功/失败/禁用账号,token 刷新
- stock: 入库审核、出库审核(含库存不足)、单号生成
- license: 激活、设备绑定验证、过期检测

Handler 集成测试(httptest + SQLite):
- auth: 登录 API 成功/401 场景
- product: CRUD 完整流程 + hotel_id 隔离验证
- stock_in: 创建→提交→审核完整流程
- stock_out: 完整流程 + 库存不足 400
- inventory: 库存查询/过滤/盘点创建
- warehouse: CRUD

覆盖率:service 90.4%,handler 56.1%
共 50 个测试用例,全部 PASS

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-04-04 07:24:14 +08:00
parent 0e42f0e417
commit 7a09d1537c
13 changed files with 2292 additions and 0 deletions
+6
View File
@@ -6,9 +6,11 @@ require (
github.com/gin-gonic/gin v1.12.0
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/spf13/viper v1.21.0
github.com/stretchr/testify v1.11.1
github.com/xuri/excelize/v2 v2.10.1
golang.org/x/crypto v0.49.0
gorm.io/driver/mysql v1.6.0
gorm.io/driver/sqlite v1.6.0
gorm.io/gorm v1.31.1
)
@@ -18,6 +20,7 @@ require (
github.com/bytedance/sonic v1.15.0 // indirect
github.com/bytedance/sonic/loader v0.5.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
@@ -34,9 +37,11 @@ require (
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-sqlite3 v1.14.22 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.59.0 // indirect
github.com/richardlehane/mscfb v1.0.6 // indirect
@@ -59,4 +64,5 @@ require (
golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.35.0 // indirect
google.golang.org/protobuf v1.36.10 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
+4
View File
@@ -58,6 +58,8 @@ github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@@ -143,5 +145,7 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg=
gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo=
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
+175
View File
@@ -0,0 +1,175 @@
package handler
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/wangjia/jiu/backend/internal/service"
"github.com/wangjia/jiu/backend/testutil"
)
func init() {
gin.SetMode(gin.TestMode)
testutil.InitConfig()
}
func newTestAuthRouter(t *testing.T) (*gin.Engine, *gin.Engine) {
db := testutil.SetupTestDB()
hotel := testutil.CreateTestHotel(db, "AUTHTEST")
testutil.CreateTestUser(db, hotel.ID, "admin", "password123", "admin")
testutil.CreateTestUser(db, hotel.ID, "disabled", "password123", "operator")
// 禁用该用户
db.Exec("UPDATE users SET is_active = 0 WHERE username = 'disabled' AND hotel_id = ?", hotel.ID)
svc := service.NewAuthService(db)
h := NewAuthHandler(svc)
r := gin.New()
r.POST("/api/v1/auth/login", h.Login)
r.POST("/api/v1/auth/refresh", h.Refresh)
return r, nil
}
func TestAuthHandler_Login_Success(t *testing.T) {
db := testutil.SetupTestDB()
hotel := testutil.CreateTestHotel(db, "AH001")
testutil.CreateTestUser(db, hotel.ID, "admin", "password123", "admin")
svc := service.NewAuthService(db)
h := NewAuthHandler(svc)
r := gin.New()
r.POST("/api/v1/auth/login", h.Login)
body := map[string]string{
"hotel_code": "AH001",
"username": "admin",
"password": "password123",
}
bodyBytes, _ := json.Marshal(body)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/api/v1/auth/login", bytes.NewBuffer(bodyBytes))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var resp map[string]interface{}
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
data := resp["data"].(map[string]interface{})
assert.NotEmpty(t, data["access_token"])
assert.NotEmpty(t, data["refresh_token"])
}
func TestAuthHandler_Login_WrongPassword(t *testing.T) {
db := testutil.SetupTestDB()
hotel := testutil.CreateTestHotel(db, "AH002")
testutil.CreateTestUser(db, hotel.ID, "admin", "password123", "admin")
svc := service.NewAuthService(db)
h := NewAuthHandler(svc)
r := gin.New()
r.POST("/api/v1/auth/login", h.Login)
body := map[string]string{
"hotel_code": "AH002",
"username": "admin",
"password": "wrongpassword",
}
bodyBytes, _ := json.Marshal(body)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/api/v1/auth/login", bytes.NewBuffer(bodyBytes))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusUnauthorized, w.Code)
}
func TestAuthHandler_Login_MissingFields(t *testing.T) {
db := testutil.SetupTestDB()
svc := service.NewAuthService(db)
h := NewAuthHandler(svc)
r := gin.New()
r.POST("/api/v1/auth/login", h.Login)
// 缺少必填字段
body := map[string]string{
"hotel_code": "AH003",
}
bodyBytes, _ := json.Marshal(body)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/api/v1/auth/login", bytes.NewBuffer(bodyBytes))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
}
func TestAuthHandler_Refresh_Success(t *testing.T) {
db := testutil.SetupTestDB()
hotel := testutil.CreateTestHotel(db, "AH004")
testutil.CreateTestUser(db, hotel.ID, "admin", "password123", "admin")
svc := service.NewAuthService(db)
h := NewAuthHandler(svc)
r := gin.New()
r.POST("/api/v1/auth/login", h.Login)
r.POST("/api/v1/auth/refresh", h.Refresh)
// 先登录获取 token
loginBody := map[string]string{
"hotel_code": "AH004",
"username": "admin",
"password": "password123",
}
loginBytes, _ := json.Marshal(loginBody)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/api/v1/auth/login", bytes.NewBuffer(loginBytes))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
var loginResp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &loginResp)
data := loginResp["data"].(map[string]interface{})
refreshToken := data["refresh_token"].(string)
// 刷新 token
refreshBody := map[string]string{"refresh_token": refreshToken}
refreshBytes, _ := json.Marshal(refreshBody)
w2 := httptest.NewRecorder()
req2, _ := http.NewRequest("POST", "/api/v1/auth/refresh", bytes.NewBuffer(refreshBytes))
req2.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w2, req2)
assert.Equal(t, http.StatusOK, w2.Code)
var resp map[string]interface{}
json.Unmarshal(w2.Body.Bytes(), &resp)
newData := resp["data"].(map[string]interface{})
assert.NotEmpty(t, newData["access_token"])
}
func TestAuthHandler_Refresh_InvalidToken(t *testing.T) {
db := testutil.SetupTestDB()
svc := service.NewAuthService(db)
h := NewAuthHandler(svc)
r := gin.New()
r.POST("/api/v1/auth/refresh", h.Refresh)
body := map[string]string{"refresh_token": "invalid.token.here"}
bodyBytes, _ := json.Marshal(body)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/api/v1/auth/refresh", bytes.NewBuffer(bodyBytes))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusUnauthorized, w.Code)
}
+254
View File
@@ -0,0 +1,254 @@
package handler
import (
"fmt"
"net/http"
"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()
hotel := testutil.CreateTestHotel(db, "INV001")
user := testutil.CreateTestUser(db, hotel.ID, "admin", "pass", "admin")
warehouse := testutil.CreateTestWarehouse(db, hotel.ID, "Warehouse")
product := testutil.CreateTestProduct(db, hotel.ID, "Beer")
token := getAuthToken(user.ID, hotel.ID, "admin")
r := setupProtectedRouter(db)
// 直接插入库存记录
inv := model.Inventory{
HotelID: hotel.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()
hotel := testutil.CreateTestHotel(db, "INV002")
user := testutil.CreateTestUser(db, hotel.ID, "admin", "pass", "admin")
warehouse1 := testutil.CreateTestWarehouse(db, hotel.ID, "W1")
warehouse2 := testutil.CreateTestWarehouse(db, hotel.ID, "W2")
product1 := testutil.CreateTestProduct(db, hotel.ID, "Beer1")
product2 := testutil.CreateTestProduct(db, hotel.ID, "Beer2")
token := getAuthToken(user.ID, hotel.ID, "admin")
r := setupProtectedRouter(db)
// 两个仓库各有一个库存
db.Create(&model.Inventory{HotelID: hotel.ID, WarehouseID: warehouse1.ID, ProductID: product1.ID, Quantity: 10})
db.Create(&model.Inventory{HotelID: hotel.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_InStockOnly(t *testing.T) {
db := testutil.SetupTestDB()
hotel := testutil.CreateTestHotel(db, "INV003")
user := testutil.CreateTestUser(db, hotel.ID, "admin", "pass", "admin")
warehouse := testutil.CreateTestWarehouse(db, hotel.ID, "Warehouse")
product1 := testutil.CreateTestProduct(db, hotel.ID, "InStock")
product2 := testutil.CreateTestProduct(db, hotel.ID, "OutOfStock")
token := getAuthToken(user.ID, hotel.ID, "admin")
r := setupProtectedRouter(db)
db.Create(&model.Inventory{HotelID: hotel.ID, WarehouseID: warehouse.ID, ProductID: product1.ID, Quantity: 10})
db.Create(&model.Inventory{HotelID: hotel.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()
hotel := testutil.CreateTestHotel(db, "INV004")
user := testutil.CreateTestUser(db, hotel.ID, "admin", "pass", "admin")
warehouse := testutil.CreateTestWarehouse(db, hotel.ID, "Warehouse")
product := testutil.CreateTestProduct(db, hotel.ID, "Wine")
token := getAuthToken(user.ID, hotel.ID, "admin")
r := setupProtectedRouter(db)
// 创建库存流水
opID := user.ID
db.Create(&model.InventoryLog{
HotelID: hotel.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()
hotel := testutil.CreateTestHotel(db, "INV005")
user := testutil.CreateTestUser(db, hotel.ID, "admin", "pass", "admin")
warehouse := testutil.CreateTestWarehouse(db, hotel.ID, "Warehouse")
product := testutil.CreateTestProduct(db, hotel.ID, "Whiskey")
token := getAuthToken(user.ID, hotel.ID, "admin")
r := setupProtectedRouter(db)
// 先创建库存
db.Create(&model.Inventory{
HotelID: hotel.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()
hotel := testutil.CreateTestHotel(db, "INV006")
user := testutil.CreateTestUser(db, hotel.ID, "admin", "pass", "admin")
warehouse := testutil.CreateTestWarehouse(db, hotel.ID, "Warehouse")
product := testutil.CreateTestProduct(db, hotel.ID, "Vodka")
token := getAuthToken(user.ID, hotel.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()
hotel := testutil.CreateTestHotel(db, "INV007")
user := testutil.CreateTestUser(db, hotel.ID, "admin", "pass", "admin")
token := getAuthToken(user.ID, hotel.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()
hotelA := testutil.CreateTestHotel(db, "INV_A")
userA := testutil.CreateTestUser(db, hotelA.ID, "adminA", "pass", "admin")
warehouseA := testutil.CreateTestWarehouse(db, hotelA.ID, "WA")
productA := testutil.CreateTestProduct(db, hotelA.ID, "ProductA")
tokenA := getAuthToken(userA.ID, hotelA.ID, "admin")
hotelB := testutil.CreateTestHotel(db, "INV_B")
userB := testutil.CreateTestUser(db, hotelB.ID, "adminB", "pass", "admin")
tokenB := getAuthToken(userB.ID, hotelB.ID, "admin")
r := setupProtectedRouter(db)
// 酒店 A 有库存
db.Create(&model.Inventory{HotelID: hotelA.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_AfterStockInApprove(t *testing.T) {
db := testutil.SetupTestDB()
hotel := testutil.CreateTestHotel(db, "INV008")
user := testutil.CreateTestUser(db, hotel.ID, "admin", "pass", "admin")
warehouse := testutil.CreateTestWarehouse(db, hotel.ID, "Warehouse")
product := testutil.CreateTestProduct(db, hotel.ID, "Champagne")
token := getAuthToken(user.ID, hotel.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"])
}
+201
View File
@@ -0,0 +1,201 @@
package handler
import (
"encoding/json"
"fmt"
"net/http"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/wangjia/jiu/backend/testutil"
)
func TestProductHandler_CRUD(t *testing.T) {
db := testutil.SetupTestDB()
hotel := testutil.CreateTestHotel(db, "PROD001")
user := testutil.CreateTestUser(db, hotel.ID, "admin", "pass", "admin")
token := getAuthToken(user.ID, hotel.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
hotelA := testutil.CreateTestHotel(db, "ISOL_A")
userA := testutil.CreateTestUser(db, hotelA.ID, "adminA", "pass", "admin")
tokenA := getAuthToken(userA.ID, hotelA.ID, "admin")
// 酒店 B
hotelB := testutil.CreateTestHotel(db, "ISOL_B")
userB := testutil.CreateTestUser(db, hotelB.ID, "adminB", "pass", "admin")
tokenB := getAuthToken(userB.ID, hotelB.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()
hotel := testutil.CreateTestHotel(db, "PROD002")
user := testutil.CreateTestUser(db, hotel.ID, "admin", "pass", "admin")
token := getAuthToken(user.ID, hotel.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()
hotel := testutil.CreateTestHotel(db, "PROD003")
user := testutil.CreateTestUser(db, hotel.ID, "admin", "pass", "admin")
token := getAuthToken(user.ID, hotel.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()
hotel := testutil.CreateTestHotel(db, "PROD004")
user := testutil.CreateTestUser(db, hotel.ID, "admin", "pass", "admin")
token := getAuthToken(user.ID, hotel.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_HotelIDFromToken(t *testing.T) {
db := testutil.SetupTestDB()
hotel := testutil.CreateTestHotel(db, "PROD005")
user := testutil.CreateTestUser(db, hotel.ID, "admin", "pass", "admin")
token := getAuthToken(user.ID, hotel.ID, "admin")
r := setupProtectedRouter(db)
// 尝试在请求体中传入不同的 hotel_id
w := makeRequest(r, "POST", "/api/v1/products", token, map[string]interface{}{
"name": "Test Product",
"hotel_id": 9999, // 尝试注入其他酒店 ID
"unit": "个",
})
require.Equal(t, http.StatusCreated, w.Code)
resp := parseResponse(w)
data := resp["data"].(map[string]interface{})
// hotel_id 应该是从 token 中获取的,而不是请求体中的
createdHotelID := uint64(data["hotel_id"].(float64))
assert.Equal(t, hotel.ID, createdHotelID)
// 反序列化验证
dataBytes, _ := json.Marshal(data)
_ = dataBytes
}
+213
View File
@@ -0,0 +1,213 @@
package handler
import (
"fmt"
"net/http"
"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 TestStockInHandler_FullFlow(t *testing.T) {
db := testutil.SetupTestDB()
hotel := testutil.CreateTestHotel(db, "SI001")
user := testutil.CreateTestUser(db, hotel.ID, "admin", "pass", "admin")
warehouse := testutil.CreateTestWarehouse(db, hotel.ID, "Main")
product := testutil.CreateTestProduct(db, hotel.ID, "Test Beer")
token := getAuthToken(user.ID, hotel.ID, "admin")
r := setupProtectedRouter(db)
// 1. 创建入库单(草稿)
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": 10.0,
"unit_price": 5.0,
},
},
})
require.Equal(t, http.StatusCreated, w.Code)
orderID := extractID(w)
assert.NotZero(t, orderID)
// 验证状态是 draft
respData := parseResponse(w)["data"].(map[string]interface{})
assert.Equal(t, "draft", respData["status"])
assert.NotEmpty(t, respData["order_no"])
// 2. 提交(draft → pending
w = makeRequest(r, "PUT", fmt.Sprintf("/api/v1/stock-in/orders/%d/submit", orderID), token, nil)
require.Equal(t, http.StatusOK, w.Code)
// 3. 获取单据详情
w = makeRequest(r, "GET", fmt.Sprintf("/api/v1/stock-in/orders/%d", orderID), token, nil)
require.Equal(t, http.StatusOK, w.Code)
detailData := parseResponse(w)["data"].(map[string]interface{})
assert.Equal(t, "pending", detailData["status"])
// 4. 审核通过
w = makeRequest(r, "PUT", fmt.Sprintf("/api/v1/stock-in/orders/%d/approve", orderID), token, nil)
require.Equal(t, http.StatusOK, w.Code)
// 5. 验证库存变化
var inv model.Inventory
db.Where("hotel_id = ? AND warehouse_id = ? AND product_id = ?",
hotel.ID, warehouse.ID, product.ID).First(&inv)
assert.Equal(t, float64(10), inv.Quantity)
// 6. 验证库存流水
var logs []model.InventoryLog
db.Where("hotel_id = ? AND product_id = ?", hotel.ID, product.ID).Find(&logs)
require.Len(t, logs, 1)
assert.Equal(t, "in", logs[0].Direction)
assert.Equal(t, float64(10), logs[0].Quantity)
}
func TestStockInHandler_List(t *testing.T) {
db := testutil.SetupTestDB()
hotel := testutil.CreateTestHotel(db, "SI002")
user := testutil.CreateTestUser(db, hotel.ID, "admin", "pass", "admin")
warehouse := testutil.CreateTestWarehouse(db, hotel.ID, "Warehouse")
product := testutil.CreateTestProduct(db, hotel.ID, "Wine")
token := getAuthToken(user.ID, hotel.ID, "admin")
r := setupProtectedRouter(db)
// 创建 2 个入库单
for i := 0; i < 2; i++ {
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": 5.0, "unit_price": 10.0},
},
})
}
w := makeRequest(r, "GET", "/api/v1/stock-in/orders", token, nil)
assert.Equal(t, http.StatusOK, w.Code)
resp := parseResponse(w)
assert.Equal(t, float64(2), resp["total"].(float64))
}
func TestStockInHandler_Submit_WrongStatus(t *testing.T) {
db := testutil.SetupTestDB()
hotel := testutil.CreateTestHotel(db, "SI003")
user := testutil.CreateTestUser(db, hotel.ID, "admin", "pass", "admin")
warehouse := testutil.CreateTestWarehouse(db, hotel.ID, "Warehouse")
product := testutil.CreateTestProduct(db, hotel.ID, "Gin")
token := getAuthToken(user.ID, hotel.ID, "admin")
r := setupProtectedRouter(db)
// 创建
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": 5.0},
},
})
orderID := extractID(w)
// 提交
makeRequest(r, "PUT", fmt.Sprintf("/api/v1/stock-in/orders/%d/submit", orderID), token, nil)
// 再次提交(应该失败,因为已经是 pending)
w = makeRequest(r, "PUT", fmt.Sprintf("/api/v1/stock-in/orders/%d/submit", orderID), token, nil)
assert.Equal(t, http.StatusBadRequest, w.Code)
}
func TestStockInHandler_Approve_NotPending(t *testing.T) {
db := testutil.SetupTestDB()
hotel := testutil.CreateTestHotel(db, "SI004")
user := testutil.CreateTestUser(db, hotel.ID, "admin", "pass", "admin")
warehouse := testutil.CreateTestWarehouse(db, hotel.ID, "Warehouse")
product := testutil.CreateTestProduct(db, hotel.ID, "Rum")
token := getAuthToken(user.ID, hotel.ID, "admin")
r := setupProtectedRouter(db)
// 创建但不提交(状态是 draft)
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": 5.0},
},
})
orderID := extractID(w)
// 直接审核(应该失败,因为是 draft 状态)
w = makeRequest(r, "PUT", fmt.Sprintf("/api/v1/stock-in/orders/%d/approve", orderID), token, nil)
assert.Equal(t, http.StatusBadRequest, w.Code)
}
func TestStockInHandler_Reject(t *testing.T) {
db := testutil.SetupTestDB()
hotel := testutil.CreateTestHotel(db, "SI005")
user := testutil.CreateTestUser(db, hotel.ID, "admin", "pass", "admin")
warehouse := testutil.CreateTestWarehouse(db, hotel.ID, "Warehouse")
product := testutil.CreateTestProduct(db, hotel.ID, "Tequila")
token := getAuthToken(user.ID, hotel.ID, "admin")
r := setupProtectedRouter(db)
// 创建并提交
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": 5.0},
},
})
orderID := extractID(w)
makeRequest(r, "PUT", fmt.Sprintf("/api/v1/stock-in/orders/%d/submit", orderID), token, nil)
// 驳回
w = makeRequest(r, "PUT", fmt.Sprintf("/api/v1/stock-in/orders/%d/reject", orderID), token, nil)
assert.Equal(t, http.StatusOK, w.Code)
// 验证状态
w = makeRequest(r, "GET", fmt.Sprintf("/api/v1/stock-in/orders/%d", orderID), token, nil)
detailData := parseResponse(w)["data"].(map[string]interface{})
assert.Equal(t, "rejected", detailData["status"])
}
func TestStockInHandler_GetNotFound(t *testing.T) {
db := testutil.SetupTestDB()
hotel := testutil.CreateTestHotel(db, "SI006")
user := testutil.CreateTestUser(db, hotel.ID, "admin", "pass", "admin")
token := getAuthToken(user.ID, hotel.ID, "admin")
r := setupProtectedRouter(db)
w := makeRequest(r, "GET", "/api/v1/stock-in/orders/99999", token, nil)
assert.Equal(t, http.StatusNotFound, w.Code)
}
func TestStockInHandler_TotalAmount(t *testing.T) {
db := testutil.SetupTestDB()
hotel := testutil.CreateTestHotel(db, "SI007")
user := testutil.CreateTestUser(db, hotel.ID, "admin", "pass", "admin")
warehouse := testutil.CreateTestWarehouse(db, hotel.ID, "Warehouse")
product1 := testutil.CreateTestProduct(db, hotel.ID, "ProductA")
product2 := testutil.CreateTestProduct(db, hotel.ID, "ProductB")
token := getAuthToken(user.ID, hotel.ID, "admin")
r := setupProtectedRouter(db)
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": product1.ID, "quantity": 10.0, "unit_price": 5.0}, // 50
{"product_id": product2.ID, "quantity": 3.0, "unit_price": 20.0}, // 60
},
})
require.Equal(t, http.StatusCreated, w.Code)
data := parseResponse(w)["data"].(map[string]interface{})
assert.Equal(t, float64(110), data["total_amount"])
}
+176
View File
@@ -0,0 +1,176 @@
package handler
import (
"fmt"
"net/http"
"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 TestStockOutHandler_FullFlow(t *testing.T) {
db := testutil.SetupTestDB()
hotel := testutil.CreateTestHotel(db, "SO001")
user := testutil.CreateTestUser(db, hotel.ID, "admin", "pass", "admin")
warehouse := testutil.CreateTestWarehouse(db, hotel.ID, "Main")
product := testutil.CreateTestProduct(db, hotel.ID, "Whiskey")
token := getAuthToken(user.ID, hotel.ID, "admin")
r := setupProtectedRouter(db)
// 先建立库存
db.Create(&model.Inventory{
HotelID: hotel.ID,
WarehouseID: warehouse.ID,
ProductID: product.ID,
Quantity: 100,
})
// 1. 创建出库单
w := makeRequest(r, "POST", "/api/v1/stock-out/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": 15.0,
"unit_price": 20.0,
},
},
})
require.Equal(t, http.StatusCreated, w.Code)
orderID := extractID(w)
assert.NotZero(t, orderID)
data := parseResponse(w)["data"].(map[string]interface{})
assert.Equal(t, "draft", data["status"])
assert.NotEmpty(t, data["order_no"])
// 2. 提交
w = makeRequest(r, "PUT", fmt.Sprintf("/api/v1/stock-out/orders/%d/submit", orderID), token, nil)
require.Equal(t, http.StatusOK, w.Code)
// 3. 获取详情
w = makeRequest(r, "GET", fmt.Sprintf("/api/v1/stock-out/orders/%d", orderID), token, nil)
require.Equal(t, http.StatusOK, w.Code)
detailData := parseResponse(w)["data"].(map[string]interface{})
assert.Equal(t, "pending", detailData["status"])
// 4. 审核通过
w = makeRequest(r, "PUT", fmt.Sprintf("/api/v1/stock-out/orders/%d/approve", orderID), token, nil)
require.Equal(t, http.StatusOK, w.Code)
// 5. 验证库存减少
var inv model.Inventory
db.Where("hotel_id = ? AND warehouse_id = ? AND product_id = ?",
hotel.ID, warehouse.ID, product.ID).First(&inv)
assert.Equal(t, float64(85), inv.Quantity)
}
func TestStockOutHandler_InsufficientStock(t *testing.T) {
db := testutil.SetupTestDB()
hotel := testutil.CreateTestHotel(db, "SO002")
user := testutil.CreateTestUser(db, hotel.ID, "admin", "pass", "admin")
warehouse := testutil.CreateTestWarehouse(db, hotel.ID, "Warehouse")
product := testutil.CreateTestProduct(db, hotel.ID, "Vodka")
token := getAuthToken(user.ID, hotel.ID, "admin")
r := setupProtectedRouter(db)
// 库存只有 5
db.Create(&model.Inventory{
HotelID: hotel.ID,
WarehouseID: warehouse.ID,
ProductID: product.ID,
Quantity: 5,
})
// 创建出库单要出 10
w := makeRequest(r, "POST", "/api/v1/stock-out/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": 10.0},
},
})
orderID := extractID(w)
makeRequest(r, "PUT", fmt.Sprintf("/api/v1/stock-out/orders/%d/submit", orderID), token, nil)
// 审核应该失败
w = makeRequest(r, "PUT", fmt.Sprintf("/api/v1/stock-out/orders/%d/approve", orderID), token, nil)
assert.Equal(t, http.StatusBadRequest, w.Code)
}
func TestStockOutHandler_List(t *testing.T) {
db := testutil.SetupTestDB()
hotel := testutil.CreateTestHotel(db, "SO003")
user := testutil.CreateTestUser(db, hotel.ID, "admin", "pass", "admin")
warehouse := testutil.CreateTestWarehouse(db, hotel.ID, "Warehouse")
product := testutil.CreateTestProduct(db, hotel.ID, "Beer")
token := getAuthToken(user.ID, hotel.ID, "admin")
r := setupProtectedRouter(db)
// 先建立库存
db.Create(&model.Inventory{HotelID: hotel.ID, WarehouseID: warehouse.ID, ProductID: product.ID, Quantity: 100})
// 创建 2 个出库单
for i := 0; i < 2; i++ {
makeRequest(r, "POST", "/api/v1/stock-out/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": 1.0},
},
})
}
w := makeRequest(r, "GET", "/api/v1/stock-out/orders", token, nil)
assert.Equal(t, http.StatusOK, w.Code)
resp := parseResponse(w)
assert.Equal(t, float64(2), resp["total"].(float64))
}
func TestStockOutHandler_Reject(t *testing.T) {
db := testutil.SetupTestDB()
hotel := testutil.CreateTestHotel(db, "SO004")
user := testutil.CreateTestUser(db, hotel.ID, "admin", "pass", "admin")
warehouse := testutil.CreateTestWarehouse(db, hotel.ID, "Warehouse")
product := testutil.CreateTestProduct(db, hotel.ID, "Rum")
token := getAuthToken(user.ID, hotel.ID, "admin")
r := setupProtectedRouter(db)
db.Create(&model.Inventory{HotelID: hotel.ID, WarehouseID: warehouse.ID, ProductID: product.ID, Quantity: 100})
w := makeRequest(r, "POST", "/api/v1/stock-out/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": 5.0},
},
})
orderID := extractID(w)
makeRequest(r, "PUT", fmt.Sprintf("/api/v1/stock-out/orders/%d/submit", orderID), token, nil)
// 驳回
w = makeRequest(r, "PUT", fmt.Sprintf("/api/v1/stock-out/orders/%d/reject", orderID), token, nil)
assert.Equal(t, http.StatusOK, w.Code)
// 验证状态
w = makeRequest(r, "GET", fmt.Sprintf("/api/v1/stock-out/orders/%d", orderID), token, nil)
detailData := parseResponse(w)["data"].(map[string]interface{})
assert.Equal(t, "rejected", detailData["status"])
}
func TestStockOutHandler_GetNotFound(t *testing.T) {
db := testutil.SetupTestDB()
hotel := testutil.CreateTestHotel(db, "SO005")
user := testutil.CreateTestUser(db, hotel.ID, "admin", "pass", "admin")
token := getAuthToken(user.ID, hotel.ID, "admin")
r := setupProtectedRouter(db)
w := makeRequest(r, "GET", "/api/v1/stock-out/orders/99999", token, nil)
assert.Equal(t, http.StatusNotFound, w.Code)
}
+140
View File
@@ -0,0 +1,140 @@
package handler
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"github.com/wangjia/jiu/backend/internal/middleware"
"github.com/wangjia/jiu/backend/internal/service"
"github.com/wangjia/jiu/backend/testutil"
)
// setupProtectedRouter 创建带 JWT 中间件的测试路由
func setupProtectedRouter(db *gorm.DB) *gin.Engine {
stockSvc := service.NewStockService(db)
licenseSvc := service.NewLicenseService(db)
productH := NewProductHandler(db)
stockInH := NewStockInHandler(db, stockSvc)
stockOutH := NewStockOutHandler(db, stockSvc)
inventoryH := NewInventoryHandler(db)
warehouseH := NewWarehouseHandler(db)
partnerH := NewPartnerHandler(db)
licenseH := NewLicenseHandler(licenseSvc)
r := gin.New()
r.Use(gin.Recovery())
api := r.Group("/api/v1")
api.Use(middleware.JWT())
// 商品路由
products := api.Group("/products")
products.GET("", productH.List)
products.POST("", productH.Create)
products.PUT("/:id", productH.Update)
products.DELETE("/:id", productH.Delete)
// 仓库路由
warehouses := api.Group("/warehouses")
warehouses.GET("", warehouseH.List)
warehouses.POST("", warehouseH.Create)
warehouses.PUT("/:id", warehouseH.Update)
warehouses.DELETE("/:id", warehouseH.Delete)
// 往来单位路由
partners := api.Group("/partners")
partners.GET("", partnerH.List)
partners.POST("", partnerH.Create)
partners.PUT("/:id", partnerH.Update)
partners.DELETE("/:id", partnerH.Delete)
// 入库路由
stockIn := api.Group("/stock-in")
stockIn.GET("/orders", stockInH.List)
stockIn.GET("/orders/:id", stockInH.Get)
stockIn.POST("/orders", stockInH.Create)
stockIn.PUT("/orders/:id/submit", stockInH.Submit)
stockIn.PUT("/orders/:id/approve", stockInH.Approve)
stockIn.PUT("/orders/:id/reject", stockInH.Reject)
// 出库路由
stockOut := api.Group("/stock-out")
stockOut.GET("/orders", stockOutH.List)
stockOut.GET("/orders/:id", stockOutH.Get)
stockOut.POST("/orders", stockOutH.Create)
stockOut.PUT("/orders/:id/submit", stockOutH.Submit)
stockOut.PUT("/orders/:id/approve", stockOutH.Approve)
stockOut.PUT("/orders/:id/reject", stockOutH.Reject)
// 库存路由
inv := api.Group("/inventory")
inv.GET("", inventoryH.List)
inv.GET("/logs", inventoryH.Logs)
inv.POST("/checks", inventoryH.CreateCheck)
inv.GET("/checks/:id", inventoryH.GetCheck)
// 许可证路由
license := api.Group("/license")
license.POST("/activate", licenseH.Activate)
license.GET("/verify", licenseH.Verify)
license.POST("/deactivate", licenseH.Deactivate)
return r
}
// makeRequest 发起带认证的请求
func makeRequest(r *gin.Engine, method, path, token string, body interface{}) *httptest.ResponseRecorder {
var bodyBytes []byte
if body != nil {
bodyBytes, _ = json.Marshal(body)
}
w := httptest.NewRecorder()
req, _ := http.NewRequest(method, path, bytes.NewBuffer(bodyBytes))
req.Header.Set("Content-Type", "application/json")
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
r.ServeHTTP(w, req)
return w
}
// parseResponse 解析 JSON 响应
func parseResponse(w *httptest.ResponseRecorder) map[string]interface{} {
var resp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &resp)
return resp
}
// getAuthToken 为测试用户获取 token
func getAuthToken(userID, hotelID uint64, role string) string {
return testutil.GetAuthToken(userID, hotelID, role)
}
// extractID 从响应 data 中提取 id
func extractID(w *httptest.ResponseRecorder) uint64 {
resp := parseResponse(w)
if data, ok := resp["data"].(map[string]interface{}); ok {
if id, ok := data["id"].(float64); ok {
return uint64(id)
}
}
return 0
}
// jsonBody 快速构建 JSON body
func jsonBody(kv ...interface{}) map[string]interface{} {
m := map[string]interface{}{}
for i := 0; i+1 < len(kv); i += 2 {
key := fmt.Sprintf("%v", kv[i])
m[key] = kv[i+1]
}
return m
}
@@ -0,0 +1,94 @@
package handler
import (
"fmt"
"net/http"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/wangjia/jiu/backend/testutil"
)
func TestWarehouseHandler_CRUD(t *testing.T) {
db := testutil.SetupTestDB()
hotel := testutil.CreateTestHotel(db, "WH001")
user := testutil.CreateTestUser(db, hotel.ID, "admin", "pass", "admin")
token := getAuthToken(user.ID, hotel.ID, "admin")
r := setupProtectedRouter(db)
// 1. Create
w := makeRequest(r, "POST", "/api/v1/warehouses", token, map[string]interface{}{
"name": "Main Warehouse",
"location": "Floor 1",
})
require.Equal(t, http.StatusCreated, w.Code)
whID := extractID(w)
assert.NotZero(t, whID)
// 2. List
w = makeRequest(r, "GET", "/api/v1/warehouses", token, nil)
assert.Equal(t, http.StatusOK, w.Code)
resp := parseResponse(w)
data := resp["data"].([]interface{})
assert.Len(t, data, 1)
// 3. Update
w = makeRequest(r, "PUT", fmt.Sprintf("/api/v1/warehouses/%d", whID), token, map[string]interface{}{
"name": "Updated Warehouse",
"location": "Floor 2",
})
assert.Equal(t, http.StatusOK, w.Code)
updatedData := parseResponse(w)["data"].(map[string]interface{})
assert.Equal(t, "Updated Warehouse", updatedData["name"])
// 4. Delete
w = makeRequest(r, "DELETE", fmt.Sprintf("/api/v1/warehouses/%d", whID), token, nil)
assert.Equal(t, http.StatusOK, w.Code)
// 5. List after delete - should be empty
w = makeRequest(r, "GET", "/api/v1/warehouses", token, nil)
resp = parseResponse(w)
data = resp["data"].([]interface{})
assert.Len(t, data, 0)
}
func TestWarehouseHandler_UpdateNotFound(t *testing.T) {
db := testutil.SetupTestDB()
hotel := testutil.CreateTestHotel(db, "WH002")
user := testutil.CreateTestUser(db, hotel.ID, "admin", "pass", "admin")
token := getAuthToken(user.ID, hotel.ID, "admin")
r := setupProtectedRouter(db)
w := makeRequest(r, "PUT", "/api/v1/warehouses/99999", token, map[string]interface{}{
"name": "Nonexistent",
})
assert.Equal(t, http.StatusNotFound, w.Code)
}
func TestWarehouseHandler_HotelIsolation(t *testing.T) {
db := testutil.SetupTestDB()
hotelA := testutil.CreateTestHotel(db, "WH_A")
userA := testutil.CreateTestUser(db, hotelA.ID, "adminA", "pass", "admin")
tokenA := getAuthToken(userA.ID, hotelA.ID, "admin")
hotelB := testutil.CreateTestHotel(db, "WH_B")
userB := testutil.CreateTestUser(db, hotelB.ID, "adminB", "pass", "admin")
tokenB := getAuthToken(userB.ID, hotelB.ID, "admin")
r := setupProtectedRouter(db)
// 酒店 A 创建仓库
w := makeRequest(r, "POST", "/api/v1/warehouses", tokenA, map[string]interface{}{
"name": "Hotel A Warehouse",
})
require.Equal(t, http.StatusCreated, w.Code)
// 酒店 B 看不到酒店 A 的仓库
w = makeRequest(r, "GET", "/api/v1/warehouses", tokenB, nil)
resp := parseResponse(w)
data := resp["data"].([]interface{})
assert.Len(t, data, 0)
}
+132
View File
@@ -0,0 +1,132 @@
package service
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/wangjia/jiu/backend/testutil"
)
func TestAuthService_Login_Success(t *testing.T) {
db := testutil.SetupTestDB()
hotel := testutil.CreateTestHotel(db, "HOTEL001")
testutil.CreateTestUser(db, hotel.ID, "admin", "password123", "admin")
svc := NewAuthService(db)
pair, user, err := svc.Login("HOTEL001", "admin", "password123")
require.NoError(t, err)
require.NotNil(t, pair)
require.NotNil(t, user)
assert.NotEmpty(t, pair.AccessToken)
assert.NotEmpty(t, pair.RefreshToken)
assert.Equal(t, "admin", user.Username)
}
func TestAuthService_Login_WrongPassword(t *testing.T) {
db := testutil.SetupTestDB()
hotel := testutil.CreateTestHotel(db, "HOTEL002")
testutil.CreateTestUser(db, hotel.ID, "admin", "password123", "admin")
svc := NewAuthService(db)
pair, user, err := svc.Login("HOTEL002", "admin", "wrongpassword")
assert.Error(t, err)
assert.Equal(t, ErrInvalidCredentials, err)
assert.Nil(t, pair)
assert.Nil(t, user)
}
func TestAuthService_Login_WrongHotel(t *testing.T) {
db := testutil.SetupTestDB()
testutil.CreateTestHotel(db, "HOTEL003")
svc := NewAuthService(db)
pair, user, err := svc.Login("NONEXISTENT", "admin", "password123")
assert.Error(t, err)
assert.Equal(t, ErrInvalidCredentials, err)
assert.Nil(t, pair)
assert.Nil(t, user)
}
func TestAuthService_Login_DisabledUser(t *testing.T) {
db := testutil.SetupTestDB()
hotel := testutil.CreateTestHotel(db, "HOTEL004")
user := testutil.CreateTestUser(db, hotel.ID, "disabled", "password123", "operator")
// 禁用用户
db.Model(user).Update("is_active", false)
svc := NewAuthService(db)
pair, u, err := svc.Login("HOTEL004", "disabled", "password123")
assert.Error(t, err)
assert.Equal(t, ErrUserInactive, err)
assert.Nil(t, pair)
assert.Nil(t, u)
}
func TestAuthService_Login_WrongUsername(t *testing.T) {
db := testutil.SetupTestDB()
hotel := testutil.CreateTestHotel(db, "HOTEL005")
testutil.CreateTestUser(db, hotel.ID, "admin", "password123", "admin")
svc := NewAuthService(db)
pair, user, err := svc.Login("HOTEL005", "nonexistent", "password123")
assert.Error(t, err)
assert.Equal(t, ErrInvalidCredentials, err)
assert.Nil(t, pair)
assert.Nil(t, user)
}
func TestAuthService_RefreshTokens(t *testing.T) {
db := testutil.SetupTestDB()
hotel := testutil.CreateTestHotel(db, "HOTEL006")
testutil.CreateTestUser(db, hotel.ID, "admin", "password123", "admin")
svc := NewAuthService(db)
pair, _, err := svc.Login("HOTEL006", "admin", "password123")
require.NoError(t, err)
require.NotNil(t, pair)
// 用 refresh token 换新 token
newPair, err := svc.RefreshTokens(pair.RefreshToken)
require.NoError(t, err)
require.NotNil(t, newPair)
assert.NotEmpty(t, newPair.AccessToken)
assert.NotEmpty(t, newPair.RefreshToken)
}
func TestAuthService_RefreshTokens_Invalid(t *testing.T) {
db := testutil.SetupTestDB()
testutil.InitConfig()
svc := NewAuthService(db)
newPair, err := svc.RefreshTokens("invalid.token.here")
assert.Error(t, err)
assert.Nil(t, newPair)
}
func TestHashPassword(t *testing.T) {
hash, err := HashPassword("mypassword")
require.NoError(t, err)
assert.NotEmpty(t, hash)
assert.NotEqual(t, "mypassword", hash)
// 第二次哈希应不同(bcrypt 加 salt
hash2, err := HashPassword("mypassword")
require.NoError(t, err)
assert.NotEqual(t, hash, hash2)
}
func TestInitConfig(t *testing.T) {
testutil.InitConfig()
}
func init() {
testutil.InitConfig()
}
+223
View File
@@ -0,0 +1,223 @@
package service
import (
"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 TestLicenseService_Activate_Success(t *testing.T) {
db := testutil.SetupTestDB()
hotel := testutil.CreateTestHotel(db, "LIC001")
// 创建许可证
expiry := time.Now().Add(30 * 24 * time.Hour)
lic := &model.License{
HotelID: hotel.ID,
LicenseKey: "AAAAA-BBBBB-CCCCC-DDDDD",
IsActive: true,
ExpiresAt: &expiry,
}
require.NoError(t, db.Create(lic).Error)
svc := NewLicenseService(db)
result, err := svc.Activate("AAAAA-BBBBB-CCCCC-DDDDD", "device-001")
require.NoError(t, err)
require.NotNil(t, result)
assert.Equal(t, "device-001", result.DeviceID)
assert.NotNil(t, result.ActivatedAt)
}
func TestLicenseService_Activate_AlreadyBoundToDifferentDevice(t *testing.T) {
db := testutil.SetupTestDB()
hotel := testutil.CreateTestHotel(db, "LIC002")
lic := &model.License{
HotelID: hotel.ID,
LicenseKey: "EEEEE-FFFFF-GGGGG-HHHHH",
DeviceID: "existing-device",
IsActive: true,
}
require.NoError(t, db.Create(lic).Error)
svc := NewLicenseService(db)
result, err := svc.Activate("EEEEE-FFFFF-GGGGG-HHHHH", "new-device")
assert.Error(t, err)
assert.Equal(t, ErrDeviceMismatch, err)
assert.Nil(t, result)
}
func TestLicenseService_Activate_SameDevice(t *testing.T) {
db := testutil.SetupTestDB()
hotel := testutil.CreateTestHotel(db, "LIC003")
lic := &model.License{
HotelID: hotel.ID,
LicenseKey: "IIIII-JJJJJ-KKKKK-LLLLL",
DeviceID: "same-device",
IsActive: true,
}
require.NoError(t, db.Create(lic).Error)
svc := NewLicenseService(db)
// 同一设备重新激活应该成功
result, err := svc.Activate("IIIII-JJJJJ-KKKKK-LLLLL", "same-device")
require.NoError(t, err)
require.NotNil(t, result)
assert.Equal(t, "same-device", result.DeviceID)
}
func TestLicenseService_Activate_NotFound(t *testing.T) {
db := testutil.SetupTestDB()
svc := NewLicenseService(db)
result, err := svc.Activate("NONEX-ISTEN-TTTTT-LICCC", "device-001")
assert.Error(t, err)
assert.Equal(t, ErrLicenseNotFound, err)
assert.Nil(t, result)
}
func TestLicenseService_Activate_Inactive(t *testing.T) {
db := testutil.SetupTestDB()
hotel := testutil.CreateTestHotel(db, "LIC004")
// 先创建激活的许可证,再禁用(避免 GORM 零值跳过问题)
lic := &model.License{
HotelID: hotel.ID,
LicenseKey: "MMMMM-NNNNN-OOOOO-PPPPP",
IsActive: true,
}
require.NoError(t, db.Create(lic).Error)
// 禁用
require.NoError(t, db.Model(lic).Update("is_active", false).Error)
svc := NewLicenseService(db)
result, err := svc.Activate("MMMMM-NNNNN-OOOOO-PPPPP", "device-001")
assert.Error(t, err)
assert.Equal(t, ErrLicenseInactive, err)
assert.Nil(t, result)
}
func TestLicenseService_Activate_Expired(t *testing.T) {
db := testutil.SetupTestDB()
hotel := testutil.CreateTestHotel(db, "LIC005")
// 已过期
expiry := time.Now().Add(-24 * time.Hour)
lic := &model.License{
HotelID: hotel.ID,
LicenseKey: "QQQQQ-RRRRR-SSSSS-TTTTT",
IsActive: true,
ExpiresAt: &expiry,
}
require.NoError(t, db.Create(lic).Error)
svc := NewLicenseService(db)
result, err := svc.Activate("QQQQQ-RRRRR-SSSSS-TTTTT", "device-001")
assert.Error(t, err)
assert.Equal(t, ErrLicenseExpired, err)
assert.Nil(t, result)
}
func TestLicenseService_Verify_Success(t *testing.T) {
db := testutil.SetupTestDB()
hotel := testutil.CreateTestHotel(db, "LIC006")
expiry := time.Now().Add(30 * 24 * time.Hour)
lic := &model.License{
HotelID: hotel.ID,
LicenseKey: "UUUUU-VVVVV-WWWWW-XXXXX",
DeviceID: "my-device",
IsActive: true,
ExpiresAt: &expiry,
}
require.NoError(t, db.Create(lic).Error)
svc := NewLicenseService(db)
result, err := svc.Verify(hotel.ID, "my-device")
require.NoError(t, err)
require.NotNil(t, result)
assert.Equal(t, "my-device", result.DeviceID)
}
func TestLicenseService_Verify_Expired(t *testing.T) {
db := testutil.SetupTestDB()
hotel := testutil.CreateTestHotel(db, "LIC007")
// 已过期
expiry := time.Now().Add(-1 * time.Hour)
lic := &model.License{
HotelID: hotel.ID,
LicenseKey: "YYYYY-ZZZZZ-AAAAA-BBBBB",
DeviceID: "expired-device",
IsActive: true,
ExpiresAt: &expiry,
}
require.NoError(t, db.Create(lic).Error)
svc := NewLicenseService(db)
result, err := svc.Verify(hotel.ID, "expired-device")
assert.Error(t, err)
assert.Equal(t, ErrLicenseExpired, err)
assert.Nil(t, result)
}
func TestLicenseService_Verify_NotFound(t *testing.T) {
db := testutil.SetupTestDB()
hotel := testutil.CreateTestHotel(db, "LIC008")
svc := NewLicenseService(db)
result, err := svc.Verify(hotel.ID, "nonexistent-device")
assert.Error(t, err)
assert.Equal(t, ErrLicenseNotFound, err)
assert.Nil(t, result)
}
func TestLicenseService_Verify_NoExpiry(t *testing.T) {
db := testutil.SetupTestDB()
hotel := testutil.CreateTestHotel(db, "LIC009")
// 永久许可证(无过期时间)
lic := &model.License{
HotelID: hotel.ID,
LicenseKey: "CCCCC-DDDDD-EEEEE-FFFFF",
DeviceID: "lifetime-device",
IsActive: true,
ExpiresAt: nil,
}
require.NoError(t, db.Create(lic).Error)
svc := NewLicenseService(db)
result, err := svc.Verify(hotel.ID, "lifetime-device")
require.NoError(t, err)
require.NotNil(t, result)
}
func TestGenerateKey(t *testing.T) {
testutil.InitConfig()
expiry := time.Now().Add(30 * 24 * time.Hour)
key := GenerateKey(1, "annual", &expiry)
assert.NotEmpty(t, key)
// 格式:XXXXX-XXXXX-XXXXX-XXXXX
assert.Equal(t, 23, len(key)) // 4*5 + 3 dashes = 23
assert.Equal(t, '-', rune(key[5]))
assert.Equal(t, '-', rune(key[11]))
assert.Equal(t, '-', rune(key[17]))
}
+296
View File
@@ -0,0 +1,296 @@
package service
import (
"fmt"
"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 TestStockService_ApproveStockIn_Success(t *testing.T) {
db := testutil.SetupTestDB()
hotel := testutil.CreateTestHotel(db, "STOCK001")
warehouse := testutil.CreateTestWarehouse(db, hotel.ID, "Main Warehouse")
product := testutil.CreateTestProduct(db, hotel.ID, "Beer")
user := testutil.CreateTestUser(db, hotel.ID, "admin", "pass", "admin")
// 创建入库单
order := model.StockInOrder{
TenantBase: model.TenantBase{HotelID: hotel.ID},
OrderNo: "IN20240101000001",
WarehouseID: warehouse.ID,
OperatorID: user.ID,
Status: "pending",
OrderDate: time.Now(),
Items: []model.StockInItem{
{
HotelID: hotel.ID,
ProductID: product.ID,
Quantity: 10,
UnitPrice: 5.0,
TotalPrice: 50.0,
},
},
}
require.NoError(t, db.Create(&order).Error)
svc := NewStockService(db)
err := svc.ApproveStockIn(hotel.ID, order.ID, user.ID)
require.NoError(t, err)
// 验证单据状态
var updated model.StockInOrder
db.First(&updated, order.ID)
assert.Equal(t, "approved", updated.Status)
// 验证库存
var inv model.Inventory
db.Where("hotel_id = ? AND warehouse_id = ? AND product_id = ?",
hotel.ID, warehouse.ID, product.ID).First(&inv)
assert.Equal(t, float64(10), inv.Quantity)
// 验证库存流水
var logs []model.InventoryLog
db.Where("hotel_id = ? AND product_id = ?", hotel.ID, product.ID).Find(&logs)
require.Len(t, logs, 1)
assert.Equal(t, "in", logs[0].Direction)
assert.Equal(t, float64(10), logs[0].Quantity)
assert.Equal(t, float64(0), logs[0].QtyBefore)
assert.Equal(t, float64(10), logs[0].QtyAfter)
}
func TestStockService_ApproveStockIn_NotPending(t *testing.T) {
db := testutil.SetupTestDB()
hotel := testutil.CreateTestHotel(db, "STOCK002")
warehouse := testutil.CreateTestWarehouse(db, hotel.ID, "Warehouse")
_ = testutil.CreateTestProduct(db, hotel.ID, "Wine")
user := testutil.CreateTestUser(db, hotel.ID, "admin", "pass", "admin")
order := model.StockInOrder{
TenantBase: model.TenantBase{HotelID: hotel.ID},
OrderNo: "IN20240101000002",
WarehouseID: warehouse.ID,
OperatorID: user.ID,
Status: "draft", // 非 pending 状态
OrderDate: time.Now(),
}
require.NoError(t, db.Create(&order).Error)
svc := NewStockService(db)
err := svc.ApproveStockIn(hotel.ID, order.ID, user.ID)
assert.Error(t, err)
assert.Contains(t, err.Error(), "not in pending status")
}
func TestStockService_ApproveStockOut_Success(t *testing.T) {
db := testutil.SetupTestDB()
hotel := testutil.CreateTestHotel(db, "STOCK003")
warehouse := testutil.CreateTestWarehouse(db, hotel.ID, "Warehouse")
product := testutil.CreateTestProduct(db, hotel.ID, "Whiskey")
user := testutil.CreateTestUser(db, hotel.ID, "admin", "pass", "admin")
// 先入库
inv := model.Inventory{
HotelID: hotel.ID,
WarehouseID: warehouse.ID,
ProductID: product.ID,
Quantity: 20,
}
require.NoError(t, db.Create(&inv).Error)
// 创建出库单
order := model.StockOutOrder{
TenantBase: model.TenantBase{HotelID: hotel.ID},
OrderNo: "OUT20240101000001",
WarehouseID: warehouse.ID,
OperatorID: user.ID,
Status: "pending",
OrderDate: time.Now(),
Items: []model.StockOutItem{
{
HotelID: hotel.ID,
ProductID: product.ID,
Quantity: 5,
UnitPrice: 10.0,
TotalPrice: 50.0,
},
},
}
require.NoError(t, db.Create(&order).Error)
svc := NewStockService(db)
err := svc.ApproveStockOut(hotel.ID, order.ID, user.ID)
require.NoError(t, err)
// 验证库存减少
var updatedInv model.Inventory
db.First(&updatedInv, inv.ID)
assert.Equal(t, float64(15), updatedInv.Quantity)
// 验证单据状态
var updatedOrder model.StockOutOrder
db.First(&updatedOrder, order.ID)
assert.Equal(t, "approved", updatedOrder.Status)
}
func TestStockService_ApproveStockOut_InsufficientStock(t *testing.T) {
db := testutil.SetupTestDB()
hotel := testutil.CreateTestHotel(db, "STOCK004")
warehouse := testutil.CreateTestWarehouse(db, hotel.ID, "Warehouse")
product := testutil.CreateTestProduct(db, hotel.ID, "Vodka")
user := testutil.CreateTestUser(db, hotel.ID, "admin", "pass", "admin")
// 库存只有 3
inv := model.Inventory{
HotelID: hotel.ID,
WarehouseID: warehouse.ID,
ProductID: product.ID,
Quantity: 3,
}
require.NoError(t, db.Create(&inv).Error)
// 要出库 10
order := model.StockOutOrder{
TenantBase: model.TenantBase{HotelID: hotel.ID},
OrderNo: "OUT20240101000002",
WarehouseID: warehouse.ID,
OperatorID: user.ID,
Status: "pending",
OrderDate: time.Now(),
Items: []model.StockOutItem{
{
HotelID: hotel.ID,
ProductID: product.ID,
Quantity: 10,
},
},
}
require.NoError(t, db.Create(&order).Error)
svc := NewStockService(db)
err := svc.ApproveStockOut(hotel.ID, order.ID, user.ID)
assert.Error(t, err)
assert.ErrorIs(t, err, ErrInsufficientStock)
}
func TestStockService_ApproveStockOut_ProductNotInInventory(t *testing.T) {
db := testutil.SetupTestDB()
hotel := testutil.CreateTestHotel(db, "STOCK005")
warehouse := testutil.CreateTestWarehouse(db, hotel.ID, "Warehouse")
product := testutil.CreateTestProduct(db, hotel.ID, "Rum")
user := testutil.CreateTestUser(db, hotel.ID, "admin", "pass", "admin")
// 没有创建库存记录
order := model.StockOutOrder{
TenantBase: model.TenantBase{HotelID: hotel.ID},
OrderNo: "OUT20240101000003",
WarehouseID: warehouse.ID,
OperatorID: user.ID,
Status: "pending",
OrderDate: time.Now(),
Items: []model.StockOutItem{
{
HotelID: hotel.ID,
ProductID: product.ID,
Quantity: 5,
},
},
}
require.NoError(t, db.Create(&order).Error)
svc := NewStockService(db)
err := svc.ApproveStockOut(hotel.ID, order.ID, user.ID)
assert.Error(t, err)
assert.Contains(t, err.Error(), "not in inventory")
}
func TestStockService_GenerateOrderNo(t *testing.T) {
db := testutil.SetupTestDB()
hotel := testutil.CreateTestHotel(db, "STOCK006")
svc := NewStockService(db)
no, err := svc.GenerateOrderNo(hotel.ID, "stock_in")
require.NoError(t, err)
assert.NotEmpty(t, no)
// 单号格式: prefix + date + 6位序号
assert.Contains(t, no, "st")
// 第二次生成序号应递增
no2, err := svc.GenerateOrderNo(hotel.ID, "stock_in")
require.NoError(t, err)
assert.NotEqual(t, no, no2)
}
// TestStockService_GenerateOrderNo_Sequential 验证顺序调用生成唯一递增单号
// 注:并发安全由 MySQL 的事务锁保证,SQLite in-memory 不模拟此场景
func TestStockService_GenerateOrderNo_Sequential(t *testing.T) {
db := testutil.SetupTestDB()
hotel := testutil.CreateTestHotel(db, "STOCK007")
svc := NewStockService(db)
const count = 5
seen := map[string]bool{}
for i := 0; i < count; i++ {
no, err := svc.GenerateOrderNo(hotel.ID, "stock_in")
require.NoError(t, err)
assert.NotEmpty(t, no)
assert.False(t, seen[no], fmt.Sprintf("duplicate order no: %s", no))
seen[no] = true
}
assert.Len(t, seen, count, "should generate exactly %d unique order numbers", count)
}
func TestStockService_MultipleStockIn_AccumulatesInventory(t *testing.T) {
db := testutil.SetupTestDB()
hotel := testutil.CreateTestHotel(db, "STOCK008")
warehouse := testutil.CreateTestWarehouse(db, hotel.ID, "Warehouse")
product := testutil.CreateTestProduct(db, hotel.ID, "Gin")
user := testutil.CreateTestUser(db, hotel.ID, "admin", "pass", "admin")
svc := NewStockService(db)
// 第一次入库
order1 := model.StockInOrder{
TenantBase: model.TenantBase{HotelID: hotel.ID},
OrderNo: "IN001",
WarehouseID: warehouse.ID,
OperatorID: user.ID,
Status: "pending",
OrderDate: time.Now(),
Items: []model.StockInItem{
{HotelID: hotel.ID, ProductID: product.ID, Quantity: 10},
},
}
require.NoError(t, db.Create(&order1).Error)
require.NoError(t, svc.ApproveStockIn(hotel.ID, order1.ID, user.ID))
// 第二次入库
order2 := model.StockInOrder{
TenantBase: model.TenantBase{HotelID: hotel.ID},
OrderNo: "IN002",
WarehouseID: warehouse.ID,
OperatorID: user.ID,
Status: "pending",
OrderDate: time.Now(),
Items: []model.StockInItem{
{HotelID: hotel.ID, ProductID: product.ID, Quantity: 5},
},
}
require.NoError(t, db.Create(&order2).Error)
require.NoError(t, svc.ApproveStockIn(hotel.ID, order2.ID, user.ID))
// 总库存应该是 15
var inv model.Inventory
db.Where("hotel_id = ? AND warehouse_id = ? AND product_id = ?",
hotel.ID, warehouse.ID, product.ID).First(&inv)
assert.Equal(t, float64(15), inv.Quantity)
}
+378
View File
@@ -0,0 +1,378 @@
package testutil
import (
"fmt"
"time"
"github.com/golang-jwt/jwt/v5"
"golang.org/x/crypto/bcrypt"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"github.com/wangjia/jiu/backend/config"
"github.com/wangjia/jiu/backend/internal/middleware"
"github.com/wangjia/jiu/backend/internal/model"
)
// InitConfig 初始化测试用配置
func InitConfig() {
config.C = config.Config{
Server: config.ServerConfig{
Port: "8080",
Mode: "test",
},
JWT: config.JWTConfig{
Secret: "test-secret-key-for-testing",
AccessExpireMin: 60,
RefreshExpireH: 168,
},
License: config.LicenseConfig{
HMACSecret: "test-license-hmac-secret",
},
}
}
// SetupTestDB 创建 SQLite in-memory 测试数据库并自动迁移
func SetupTestDB() *gorm.DB {
InitConfig()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
panic(fmt.Sprintf("failed to open sqlite: %v", err))
}
// SQLite 不支持 ENUM,直接用原始 SQL 建表
stmts := []string{
`CREATE TABLE IF NOT EXISTS hotels (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at DATETIME,
updated_at DATETIME,
deleted_at DATETIME,
name TEXT NOT NULL,
code TEXT UNIQUE,
address TEXT,
phone TEXT,
custom_fields TEXT
)`,
`CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at DATETIME,
updated_at DATETIME,
deleted_at DATETIME,
hotel_id INTEGER NOT NULL,
username TEXT,
password_hash TEXT,
real_name TEXT,
phone TEXT,
role TEXT DEFAULT 'operator',
is_active INTEGER DEFAULT 1,
custom_fields TEXT
)`,
`CREATE UNIQUE INDEX IF NOT EXISTS uk_hotel_username ON users(hotel_id, username)`,
`CREATE TABLE IF NOT EXISTS licenses (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at DATETIME,
updated_at DATETIME,
deleted_at DATETIME,
hotel_id INTEGER NOT NULL,
license_key TEXT UNIQUE,
device_id TEXT,
type TEXT DEFAULT 'trial',
expires_at DATETIME,
is_active INTEGER DEFAULT 1,
features TEXT,
activated_at DATETIME
)`,
`CREATE TABLE IF NOT EXISTS product_categories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at DATETIME,
updated_at DATETIME,
deleted_at DATETIME,
hotel_id INTEGER NOT NULL,
name TEXT NOT NULL,
parent_id INTEGER,
sort_order INTEGER DEFAULT 0
)`,
`CREATE TABLE IF NOT EXISTS products (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at DATETIME,
updated_at DATETIME,
deleted_at DATETIME,
hotel_id INTEGER NOT NULL,
code TEXT,
barcode TEXT,
name TEXT NOT NULL,
series TEXT,
spec TEXT,
unit TEXT,
category_id INTEGER,
brand TEXT,
purchase_price REAL,
sale_price REAL,
min_stock INTEGER DEFAULT 0,
custom_fields TEXT,
remark TEXT
)`,
`CREATE TABLE IF NOT EXISTS warehouses (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at DATETIME,
updated_at DATETIME,
deleted_at DATETIME,
hotel_id INTEGER NOT NULL,
name TEXT NOT NULL,
location TEXT,
is_default INTEGER DEFAULT 0,
custom_fields TEXT
)`,
`CREATE TABLE IF NOT EXISTS partners (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at DATETIME,
updated_at DATETIME,
deleted_at DATETIME,
hotel_id INTEGER NOT NULL,
code TEXT,
name TEXT NOT NULL,
type TEXT DEFAULT 'supplier',
contact TEXT,
phone TEXT,
address TEXT,
bank_account TEXT,
credit_limit REAL,
balance REAL DEFAULT 0,
custom_fields TEXT,
remark TEXT
)`,
`CREATE TABLE IF NOT EXISTS stock_in_orders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at DATETIME,
updated_at DATETIME,
deleted_at DATETIME,
hotel_id INTEGER NOT NULL,
order_no TEXT,
type TEXT DEFAULT 'purchase',
warehouse_id INTEGER NOT NULL,
partner_id INTEGER,
operator_id INTEGER NOT NULL,
reviewer_id INTEGER,
status TEXT DEFAULT 'draft',
order_date DATETIME,
total_amount REAL DEFAULT 0,
reviewed_at DATETIME,
custom_fields TEXT,
remark TEXT
)`,
`CREATE UNIQUE INDEX IF NOT EXISTS uk_hotel_sio_order_no ON stock_in_orders(hotel_id, order_no)`,
`CREATE TABLE IF NOT EXISTS stock_in_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at DATETIME,
updated_at DATETIME,
deleted_at DATETIME,
order_id INTEGER NOT NULL,
hotel_id INTEGER NOT NULL,
product_id INTEGER NOT NULL,
quantity REAL NOT NULL,
unit_price REAL DEFAULT 0,
total_price REAL DEFAULT 0,
batch_no TEXT,
custom_fields TEXT,
remark TEXT
)`,
`CREATE TABLE IF NOT EXISTS stock_out_orders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at DATETIME,
updated_at DATETIME,
deleted_at DATETIME,
hotel_id INTEGER NOT NULL,
order_no TEXT,
type TEXT DEFAULT 'sale',
warehouse_id INTEGER NOT NULL,
partner_id INTEGER,
operator_id INTEGER NOT NULL,
reviewer_id INTEGER,
status TEXT DEFAULT 'draft',
order_date DATETIME,
total_amount REAL DEFAULT 0,
reviewed_at DATETIME,
custom_fields TEXT,
remark TEXT
)`,
`CREATE UNIQUE INDEX IF NOT EXISTS uk_hotel_soo_order_no ON stock_out_orders(hotel_id, order_no)`,
`CREATE TABLE IF NOT EXISTS stock_out_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at DATETIME,
updated_at DATETIME,
deleted_at DATETIME,
order_id INTEGER NOT NULL,
hotel_id INTEGER NOT NULL,
product_id INTEGER NOT NULL,
quantity REAL NOT NULL,
unit_price REAL DEFAULT 0,
total_price REAL DEFAULT 0,
custom_fields TEXT,
remark TEXT
)`,
`CREATE TABLE IF NOT EXISTS inventories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
hotel_id INTEGER NOT NULL,
warehouse_id INTEGER NOT NULL,
product_id INTEGER NOT NULL,
quantity REAL DEFAULT 0,
updated_at DATETIME,
UNIQUE(hotel_id, warehouse_id, product_id)
)`,
`CREATE TABLE IF NOT EXISTS inventory_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
hotel_id INTEGER NOT NULL,
warehouse_id INTEGER NOT NULL,
product_id INTEGER NOT NULL,
direction TEXT,
quantity REAL,
qty_before REAL,
qty_after REAL,
ref_type TEXT,
ref_id INTEGER,
operator_id INTEGER,
created_at DATETIME
)`,
`CREATE TABLE IF NOT EXISTS inventory_checks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at DATETIME,
updated_at DATETIME,
deleted_at DATETIME,
hotel_id INTEGER NOT NULL,
check_no TEXT,
warehouse_id INTEGER NOT NULL,
operator_id INTEGER NOT NULL,
status TEXT DEFAULT 'draft',
check_date DATETIME,
remark TEXT
)`,
`CREATE UNIQUE INDEX IF NOT EXISTS uk_hotel_check_no ON inventory_checks(hotel_id, check_no)`,
`CREATE TABLE IF NOT EXISTS inventory_check_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at DATETIME,
updated_at DATETIME,
deleted_at DATETIME,
check_id INTEGER NOT NULL,
hotel_id INTEGER NOT NULL,
product_id INTEGER NOT NULL,
system_qty REAL,
actual_qty REAL,
remark TEXT
)`,
`CREATE TABLE IF NOT EXISTS number_rules (
id INTEGER PRIMARY KEY AUTOINCREMENT,
hotel_id INTEGER NOT NULL,
type TEXT,
prefix TEXT DEFAULT '',
current_no INTEGER DEFAULT 0,
date_format TEXT DEFAULT 'YYYYMMDD',
updated_at DATETIME,
UNIQUE(hotel_id, type)
)`,
}
for _, stmt := range stmts {
if err := db.Exec(stmt).Error; err != nil {
panic(fmt.Sprintf("failed to create table: %v\nSQL: %s", err, stmt))
}
}
return db
}
// CreateTestHotel 创建测试酒店
func CreateTestHotel(db *gorm.DB, code string) *model.Hotel {
hotel := &model.Hotel{
Name: "Test Hotel " + code,
Code: code,
}
if err := db.Create(hotel).Error; err != nil {
panic(fmt.Sprintf("failed to create test hotel: %v", err))
}
return hotel
}
// hashPassword 内部使用的密码哈希函数,避免循环依赖
func hashPassword(plain string) string {
b, err := bcrypt.GenerateFromPassword([]byte(plain), bcrypt.DefaultCost)
if err != nil {
panic(fmt.Sprintf("failed to hash password: %v", err))
}
return string(b)
}
// CreateTestUser 创建测试用户
func CreateTestUser(db *gorm.DB, hotelID uint64, username, password, role string) *model.User {
hash := hashPassword(password)
user := &model.User{
TenantBase: model.TenantBase{
Base: model.Base{},
HotelID: hotelID,
},
Username: username,
PasswordHash: hash,
RealName: "Test User " + username,
Role: role,
IsActive: true,
}
if err := db.Create(user).Error; err != nil {
panic(fmt.Sprintf("failed to create test user: %v", err))
}
return user
}
// CreateTestWarehouse 创建测试仓库
func CreateTestWarehouse(db *gorm.DB, hotelID uint64, name string) *model.Warehouse {
w := &model.Warehouse{
TenantBase: model.TenantBase{
HotelID: hotelID,
},
Name: name,
IsDefault: true,
}
if err := db.Create(w).Error; err != nil {
panic(fmt.Sprintf("failed to create test warehouse: %v", err))
}
return w
}
// CreateTestProduct 创建测试商品
func CreateTestProduct(db *gorm.DB, hotelID uint64, name string) *model.Product {
p := &model.Product{
TenantBase: model.TenantBase{
HotelID: hotelID,
},
Name: name,
Code: "P-" + name,
Unit: "个",
}
if err := db.Create(p).Error; err != nil {
panic(fmt.Sprintf("failed to create test product: %v", err))
}
return p
}
// GetAuthToken 生成测试 JWT token
func GetAuthToken(userID, hotelID uint64, role string) string {
InitConfig()
now := time.Now()
claims := middleware.Claims{
UserID: userID,
HotelID: hotelID,
Role: role,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(now.Add(time.Hour)),
IssuedAt: jwt.NewNumericDate(now),
},
}
token, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).
SignedString([]byte(config.C.JWT.Secret))
if err != nil {
panic(fmt.Sprintf("failed to sign token: %v", err))
}
return token
}