diff --git a/backend/go.mod b/backend/go.mod index 4ed366d..f1354b4 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -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 ) diff --git a/backend/go.sum b/backend/go.sum index d0d47cb..0d53ede 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -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= diff --git a/backend/internal/handler/auth_test.go b/backend/internal/handler/auth_test.go new file mode 100644 index 0000000..ecc886f --- /dev/null +++ b/backend/internal/handler/auth_test.go @@ -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) +} diff --git a/backend/internal/handler/inventory_test.go b/backend/internal/handler/inventory_test.go new file mode 100644 index 0000000..eed834e --- /dev/null +++ b/backend/internal/handler/inventory_test.go @@ -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"]) +} diff --git a/backend/internal/handler/product_test.go b/backend/internal/handler/product_test.go new file mode 100644 index 0000000..ace26e6 --- /dev/null +++ b/backend/internal/handler/product_test.go @@ -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 +} diff --git a/backend/internal/handler/stock_in_test.go b/backend/internal/handler/stock_in_test.go new file mode 100644 index 0000000..2471c77 --- /dev/null +++ b/backend/internal/handler/stock_in_test.go @@ -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"]) +} diff --git a/backend/internal/handler/stock_out_test.go b/backend/internal/handler/stock_out_test.go new file mode 100644 index 0000000..ce29b6c --- /dev/null +++ b/backend/internal/handler/stock_out_test.go @@ -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) +} diff --git a/backend/internal/handler/testhelper_test.go b/backend/internal/handler/testhelper_test.go new file mode 100644 index 0000000..7b5b189 --- /dev/null +++ b/backend/internal/handler/testhelper_test.go @@ -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 +} diff --git a/backend/internal/handler/warehouse_test.go b/backend/internal/handler/warehouse_test.go new file mode 100644 index 0000000..ea6ca45 --- /dev/null +++ b/backend/internal/handler/warehouse_test.go @@ -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) +} diff --git a/backend/internal/service/auth_test.go b/backend/internal/service/auth_test.go new file mode 100644 index 0000000..4b8fb65 --- /dev/null +++ b/backend/internal/service/auth_test.go @@ -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() +} diff --git a/backend/internal/service/license_test.go b/backend/internal/service/license_test.go new file mode 100644 index 0000000..2b5c42b --- /dev/null +++ b/backend/internal/service/license_test.go @@ -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])) +} diff --git a/backend/internal/service/stock_test.go b/backend/internal/service/stock_test.go new file mode 100644 index 0000000..4038edc --- /dev/null +++ b/backend/internal/service/stock_test.go @@ -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) +} diff --git a/backend/testutil/setup.go b/backend/testutil/setup.go new file mode 100644 index 0000000..db9fe6c --- /dev/null +++ b/backend/testutil/setup.go @@ -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 +}