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:
@@ -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