material_manage_system/internal/handler/order_flow_integration_test.go
2026-10-09 12:21:53 +08:00

232 lines
9.1 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package handler
import (
"database/sql"
"encoding/json"
"fmt"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/go-sql-driver/mysql"
"github.com/wmms/wmms/pkg/database"
)
// TestOrderFlowIsolatesCompanyInventoryMySQL 用一张一次性 MySQL 库做端到端验收:
// A 公司的入库/出库/编辑/删除只改变 A 公司的库存与单据,B 公司完全不受影响;
// 库存不足、仓库停用等失败不会留下半张单据或孤立流水。真实库用例,未配 TEST_DB_DSN 时 skip。
func TestOrderFlowIsolatesCompanyInventoryMySQL(t *testing.T) {
dsn := os.Getenv("TEST_DB_DSN")
if dsn == "" {
t.Skip("real MySQL integration requires TEST_DB_DSN")
}
cfg, err := mysql.ParseDSN(dsn)
if err != nil {
t.Fatal(err)
}
if !strings.HasPrefix(cfg.DBName, "company_isolation_test") {
t.Fatal("TEST_DB_DSN must name a company_isolation_test schema")
}
cfg.DBName = ""
cfg.MultiStatements = true
cfg.ParseTime = true
admin, err := sql.Open("mysql", cfg.FormatDSN())
if err != nil {
t.Fatal(err)
}
name := fmt.Sprintf("company_isolation_test_order_%d", time.Now().UnixNano())
if _, err = admin.Exec("CREATE DATABASE `" + name + "` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"); err != nil {
admin.Close()
t.Fatal(err)
}
cfg.DBName = name
db, err := sql.Open("mysql", cfg.FormatDSN())
if err != nil {
admin.Exec("DROP DATABASE `" + name + "`")
admin.Close()
t.Fatal(err)
}
previous := database.DB
database.DB = db
t.Cleanup(func() { database.DB = previous; db.Close(); admin.Exec("DROP DATABASE `" + name + "`"); admin.Close() })
content, err := os.ReadFile(filepath.Join("..", "..", "scripts", "init.sql"))
if err != nil {
t.Fatal(err)
}
var lines []string
for _, line := range strings.Split(strings.TrimPrefix(string(content), "\ufeff"), "\n") {
if strings.HasPrefix(line, "CREATE DATABASE") || strings.HasPrefix(line, "USE ") {
continue
}
lines = append(lines, line)
}
exec := func(query string, args ...interface{}) {
t.Helper()
if _, err := db.Exec(query, args...); err != nil {
t.Fatalf("fixture SQL: %v\n%s", err, query)
}
}
exec(strings.Join(lines, "\n"))
// 公司 A=201、B=202 各配一组启用仓库、库位与一个物料,余额 A=10、B=50;
// 用户 7 有 warehouse_admin 角色,并同时被授权公司 A、B。
exec(`INSERT INTO organizations(id,code,name,status) VALUES (201,'OI_A','公司A','active'),(202,'OI_B','公司B','active');
INSERT INTO users(id,username,password) VALUES (7,'order-user','x');
INSERT INTO user_roles(user_id,role_id) SELECT 7,id FROM roles WHERE code='warehouse_admin';
INSERT INTO user_organizations(user_id,organization_id,is_default) VALUES (7,201,1),(7,202,0);
INSERT INTO material_categories(id,organization_id,code,name) VALUES (201,201,'C','C'),(202,202,'C','C');
INSERT INTO units(id,organization_id,code,name) VALUES (201,201,'U','U'),(202,202,'U','U');
INSERT INTO materials(id,organization_id,material_code,name,spec,unit,category_id,base_unit_id) VALUES (201,201,'MA','A','','pcs',201,201),(202,202,'MB','B','','pcs',202,202);
INSERT INTO warehouses(id,organization_id,code,name,status) VALUES (201,201,'W','W','active'),(202,202,'W','W','active');
INSERT INTO warehouse_locations(id,warehouse_id,code,name,status) VALUES (201,201,'L','L','active'),(202,202,'L','L','active');
INSERT INTO inventory_balances(organization_id,warehouse_id,location_id,material_id,on_hand) VALUES (201,201,201,201,10),(202,202,202,202,50);`)
token := contextToken(t)
gin.SetMode(gin.TestMode)
router := gin.New()
NewHandler().RegisterRoutes(router)
request := func(method, path, body string, org int64) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(method, path, strings.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("X-Organization-ID", fmt.Sprint(org))
if body != "" {
req.Header.Set("Content-Type", "application/json")
}
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
return w
}
expectStatus := func(w *httptest.ResponseRecorder, status int) {
t.Helper()
if w.Code != status {
t.Fatalf("status=%d body=%s, want %d", w.Code, w.Body.String(), status)
}
}
balance := func(org, material int64) string {
t.Helper()
var onHand string
if err := db.QueryRow(`SELECT COALESCE(on_hand,0) FROM inventory_balances WHERE organization_id=? AND material_id=?`, org, material).Scan(&onHand); err != nil {
t.Fatal(err)
}
return onHand
}
orderCount := func(table string, org int64) int {
t.Helper()
var count int
if err := db.QueryRow(`SELECT COUNT(*) FROM `+table+` WHERE organization_id=?`, org).Scan(&count); err != nil {
t.Fatal(err)
}
return count
}
orphanLogs := func(org int64) int {
t.Helper()
var count int
if err := db.QueryRow(`SELECT COUNT(*) FROM stock_logs sl WHERE sl.organization_id=? AND ((sl.related_order_type='inbound' AND sl.related_order_id NOT IN (SELECT id FROM inbound_orders WHERE organization_id=?)) OR (sl.related_order_type='outbound' AND sl.related_order_id NOT IN (SELECT id FROM outbound_orders WHERE organization_id=?)))`, org, org, org).Scan(&count); err != nil {
t.Fatal(err)
}
return count
}
aBalanceSteps := []struct{ label, want string }{
{"初始", "10.000000"},
{"A 入库 5 后", "15.000000"},
{"A 出库 3 后", "12.000000"},
{"A 编辑入库 5→8 后", "15.000000"},
{"A 删除入库单后", "7.000000"},
}
if got := balance(201, 201); got != aBalanceSteps[0].want {
t.Fatalf("初始 A 余额=%s, want %s", got, aBalanceSteps[0].want)
}
if got := balance(202, 202); got != "50.000000" {
t.Fatalf("初始 B 余额=%s, want 50.000000", got)
}
// 1) A 入库 5。
w := request("POST", "/api/inbound", `{"handler":"h","supplier":"s","items":[{"material_id":201,"quantity":5}]}`, 201)
expectStatus(w, 201)
var inbound struct {
Data struct {
ID int64 `json:"id"`
} `json:"data"`
}
if err := json.Unmarshal(w.Body.Bytes(), &inbound); err != nil || inbound.Data.ID == 0 {
t.Fatalf("解析入库单失败: %v body=%s", err, w.Body.String())
}
if got := balance(201, 201); got != aBalanceSteps[1].want {
t.Fatalf("A 入库后余额=%s, want %s", got, aBalanceSteps[1].want)
}
// 2) A 出库 3。
expectStatus(request("POST", "/api/outbound", `{"receiver":"r","items":[{"material_id":201,"quantity":3}]}`, 201), 201)
if got := balance(201, 201); got != aBalanceSteps[2].want {
t.Fatalf("A 出库后余额=%s, want %s", got, aBalanceSteps[2].want)
}
// 3) A 编辑入库单 5→8。
expectStatus(request("PUT", fmt.Sprintf("/api/inbound/%d", inbound.Data.ID), `{"handler":"h","supplier":"s","items":[{"material_id":201,"quantity":8}]}`, 201), 200)
if got := balance(201, 201); got != aBalanceSteps[3].want {
t.Fatalf("A 编辑后余额=%s, want %s", got, aBalanceSteps[3].want)
}
// 4) A 删除入库单。
expectStatus(request("DELETE", fmt.Sprintf("/api/inbound/%d", inbound.Data.ID), "", 201), 200)
if got := balance(201, 201); got != aBalanceSteps[4].want {
t.Fatalf("A 删除后余额=%s, want %s", got, aBalanceSteps[4].want)
}
// 至此 A 的库存与单据变化只落在 A 上,B 一直是 50、无单据无流水。
if got := balance(202, 202); got != "50.000000" {
t.Fatalf("A 操作后 B 余额=%s, want 50.000000", got)
}
if orderCount("inbound_orders", 202) != 0 || orderCount("outbound_orders", 202) != 0 {
t.Fatalf("A 操作影响了 B 的单据")
}
if got := orderCount("outbound_orders", 201); got != 1 {
t.Fatalf("A 出库单数=%d, want 1", got)
}
// 5) 库存不足:A 出库 100 失败,余额不变,不留半张出库单或孤立流水。
logsBefore := countLogs(t, db, 201)
expectStatus(request("POST", "/api/outbound", `{"receiver":"r","items":[{"material_id":201,"quantity":100}]}`, 201), 400)
if got := balance(201, 201); got != aBalanceSteps[4].want {
t.Fatalf("库存不足后 A 余额=%s, want %s", got, aBalanceSteps[4].want)
}
if got := orderCount("outbound_orders", 201); got != 1 {
t.Fatalf("库存不足留下了半张出库单: %d 张", got)
}
if countLogs(t, db, 201) != logsBefore || orphanLogs(201) != 0 {
t.Fatalf("库存不足留下了孤立流水")
}
// 6) 仓库停用:改变库存的入库请求回 409,不落任何单据。
exec(`UPDATE warehouses SET status='inactive' WHERE organization_id=201`)
logsBefore = countLogs(t, db, 201)
expectStatus(request("POST", "/api/inbound", `{"handler":"h","supplier":"s","items":[{"material_id":201,"quantity":1}]}`, 201), 409)
if got := orderCount("inbound_orders", 201); got != 0 {
t.Fatalf("仓库停用留下了半张入库单: %d 张", got)
}
if countLogs(t, db, 201) != logsBefore || orphanLogs(201) != 0 {
t.Fatalf("仓库停用留下了孤立流水")
}
if got := balance(201, 201); got != aBalanceSteps[4].want {
t.Fatalf("仓库停用后 A 余额=%s, want %s", got, aBalanceSteps[4].want)
}
if got := balance(202, 202); got != "50.000000" {
t.Fatalf("失败操作影响了 B 余额: %s", got)
}
}
func countLogs(t *testing.T, db *sql.DB, organizationID int64) int {
t.Helper()
var count int
if err := db.QueryRow(`SELECT COUNT(*) FROM stock_logs WHERE organization_id=?`, organizationID).Scan(&count); err != nil {
t.Fatal(err)
}
return count
}