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:
@@ -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
|
||||
)
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"])
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"])
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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]))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user