material_manage_system/internal/handler/order_organization_test.go
文睿 张 5021b039c9 feat: 按公司隔离 WebSocket 订阅与事件投递
- 新增 POST /api/auth/websocket-ticket:需 JWT 与公司头,签发 30 秒有效的一次性随机票据
- /ws 握手消费票据并以数据库当前状态复核身份与公司授权,JWT 不进 URL
- Hub 注册/注销/投递选人统一由事件循环串行管理,消除 RLock 内 delete 的并发缺陷
- 事件携带 organization_id,仅投递同公司订阅者,投递前复核失败即关闭连接
- 统一广播签名为 (organizationID, action, orderID, data)
- 新增前端 companySocket 模块:按当前公司取票建连、切换/登出关旧连重连、消息校验组织与上下文版本
2026-10-09 13:39:45 +08:00

245 lines
9.4 KiB
Go
Raw 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 (
"strconv"
"strings"
"testing"
"time"
"github.com/DATA-DOG/go-sqlmock"
)
// 本文件验收 Task 5B 的入出库单据接口隔离:
// 1. 单据详情、按物料历史、编辑、删除都带请求头公司,A 读/改/删 B 的单据返回 404;
// 2. 未配置启用仓库、库位时,改变库存的新建请求返回 409 与具体配置提示。
//
// 中间件链:AuthMiddleware → OrganizationMiddleware → 业务 Handler,所有 SQL 期望
// 都必须带对组织参数,参数对不上 sqlmock 直接报错。
//
// 与 repository 里的查询字面量对得上的稳定子串。
const (
inboundHeadPattern = `SELECT id, COALESCE\(inbound_no`
outboundHeadPattern = `SELECT id, COALESCE\(outbound_no`
inboundByMatPattern = `SELECT ii.id, ii.inbound_order_id`
outboundByMatPattern = `SELECT oi.id, oi.outbound_order_id`
orderWarehousePattern = `SELECT w.id, w.organization_id`
)
func orderHeadRow(prefix string, id, organizationID int64) *sqlmock.Rows {
now := time.Now()
return sqlmock.NewRows([]string{"id", "order_no", "operator", "order_date", "handler", "party", "remark", "organization_id", "warehouse_id", "location_id", "created_at", "updated_at"}).
AddRow(id, prefix+strconv.FormatInt(id, 10), "", now, "h", "p", "", organizationID, 10, 20, now, now)
}
func emptyOrderHeadRows() *sqlmock.Rows {
return sqlmock.NewRows([]string{"id", "order_no", "operator", "order_date", "handler", "party", "remark", "organization_id", "warehouse_id", "location_id", "created_at", "updated_at"})
}
func emptyInboundRecords() *sqlmock.Rows {
return sqlmock.NewRows([]string{"id", "inbound_id", "handler", "supplier", "quantity", "created_at"})
}
func emptyOutboundRecords() *sqlmock.Rows {
return sqlmock.NewRows([]string{"id", "outbound_id", "handler", "receiver", "quantity", "created_at"})
}
func emptyOrderItems() *sqlmock.Rows {
return sqlmock.NewRows([]string{"id", "order_id", "material_id", "quantity", "material_code", "name", "status"})
}
func emptyWarehouseSummary() *sqlmock.Rows {
return sqlmock.NewRows([]string{"id", "organization_id", "code", "name", "status", "location_id", "location_code", "location_name", "location_status"})
}
func activeMaterialRows(id int64) *sqlmock.Rows {
now := time.Now()
return sqlmock.NewRows(materialColumns()).
AddRow(id, 1, "MAT-005", "轴承", nil, "", nil, 1, 1, "个", "active", 1, now, now)
}
// TestOrderDetailScopesToOrganization:单据详情按请求公司过滤,传别家公司的单据 ID 读不到,回 404。
func TestOrderDetailScopesToOrganization(t *testing.T) {
for _, tc := range []struct {
name string
id int64
path string
head string
items string
found bool
prefix string
wantCode int
}{
{"inbound own", 101, "/api/inbound/101", inboundHeadPattern, inboundByMatPattern, true, "IN-", 200},
{"inbound cross", 102, "/api/inbound/102", inboundHeadPattern, inboundByMatPattern, false, "IN-", 404},
{"outbound own", 101, "/api/outbound/101", outboundHeadPattern, outboundByMatPattern, true, "OUT-", 200},
{"outbound cross", 102, "/api/outbound/102", outboundHeadPattern, outboundByMatPattern, false, "OUT-", 404},
} {
t.Run(tc.name, func(t *testing.T) {
mock := contextMock(t)
expectContextPrincipal(mock, "warehouse_admin")
expectMaterialOrgAccess(mock, 1)
headRows := emptyOrderHeadRows()
if tc.found {
headRows = orderHeadRow(tc.prefix, tc.id, 1)
}
mock.ExpectQuery(tc.head).WithArgs(tc.id, int64(1)).WillReturnRows(headRows)
if tc.found {
mock.ExpectQuery(tc.items).WithArgs(tc.id).WillReturnRows(emptyOrderItems())
}
w := newStockRequest(t, "GET", tc.path, "", 1)
if w.Code != tc.wantCode {
t.Fatalf("status=%d body=%s, want %d", w.Code, w.Body.String(), tc.wantCode)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
})
}
}
// TestOrderByMaterialScopesToOrganization:按物料历史记录带请求公司查询,
// A 上下文读 B 的物料只会拿到空列表,不泄露别家公司的入出库记录。
func TestOrderByMaterialScopesToOrganization(t *testing.T) {
for _, tc := range []struct {
name string
path string
pattern string
rows func() *sqlmock.Rows
}{
{"inbound by material", "/api/inbound/materials/102", inboundByMatPattern, emptyInboundRecords},
{"outbound by material", "/api/outbound/materials/102", outboundByMatPattern, emptyOutboundRecords},
} {
t.Run(tc.name, func(t *testing.T) {
mock := contextMock(t)
expectContextPrincipal(mock, "warehouse_admin")
expectMaterialOrgAccess(mock, 1)
// 物料 102 属于公司 2:带 organization_id=1 的历史查询参数必须是 (102, 1)。
mock.ExpectQuery(tc.pattern).WithArgs(int64(102), int64(1)).WillReturnRows(tc.rows())
w := newStockRequest(t, "GET", tc.path, "", 1)
if w.Code != 200 {
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
})
}
}
// TestOrderDeleteCrossOrganizationReturnsNotFound:删除别家公司的单据时,事务内锁定
// 当前公司原单据命中 ErrNoRows,接口回 404,且不执行任何冲销。
func TestOrderDeleteCrossOrganizationReturnsNotFound(t *testing.T) {
for _, tc := range []struct {
name string
path string
pattern string
}{
{"inbound", "/api/inbound/102", inboundHeadPattern},
{"outbound", "/api/outbound/102", outboundHeadPattern},
} {
t.Run(tc.name, func(t *testing.T) {
mock := contextMock(t)
expectContextPrincipal(mock, "warehouse_admin")
expectMaterialOrgAccess(mock, 1)
mock.ExpectBegin()
mock.ExpectQuery(tc.pattern).WithArgs(int64(102), int64(1)).WillReturnRows(emptyOrderHeadRows())
mock.ExpectRollback()
w := newStockRequest(t, "DELETE", tc.path, "", 1)
if w.Code != 404 {
t.Fatalf("status=%d body=%s, want 404", w.Code, w.Body.String())
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
})
}
}
// TestOrderUpdateCrossOrganizationReturnsNotFound:编辑别家公司的单据时,读取原单据
// 命中 ErrNoRows,接口回 404,不落任何改动。
func TestOrderUpdateCrossOrganizationReturnsNotFound(t *testing.T) {
body := `{"items":[{"material_id":7,"quantity":1}]}`
for _, tc := range []struct {
name string
path string
pattern string
}{
{"inbound", "/api/inbound/102", inboundHeadPattern},
{"outbound", "/api/outbound/102", outboundHeadPattern},
} {
t.Run(tc.name, func(t *testing.T) {
mock := contextMock(t)
expectContextPrincipal(mock, "warehouse_admin")
expectMaterialOrgAccess(mock, 1)
mock.ExpectQuery(tc.pattern).WithArgs(int64(102), int64(1)).WillReturnRows(emptyOrderHeadRows())
w := newStockRequest(t, "PUT", tc.path, body, 1)
if w.Code != 404 {
t.Fatalf("status=%d body=%s, want 404", w.Code, w.Body.String())
}
// 只登记了归属读取:一旦越过它去改明细,sqlmock 会因未预期语句报错。
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
})
}
}
// TestCreateOrderWithoutWarehouseReturnsConflict:改变库存的新建请求在公司未配置
// 启用仓库、库位时回 409 与具体配置提示,不回退默认公司。
func TestCreateOrderWithoutWarehouseReturnsConflict(t *testing.T) {
inboundBody := `{"handler":"h","supplier":"s","items":[{"material_id":5,"quantity":1}]}`
outboundBody := `{"receiver":"r","items":[{"material_id":5,"quantity":1}]}`
t.Run("inbound", func(t *testing.T) {
mock := contextMock(t)
expectContextPrincipal(mock, "warehouse_admin")
expectMaterialOrgAccess(mock, 1)
// 入库先校验明细物料,再解析库存维度;物料合法但公司无仓库 → 409。
mock.ExpectQuery(materialGetByIDPattern).WithArgs(int64(1), int64(5)).WillReturnRows(activeMaterialRows(5))
mock.ExpectQuery(materialGetByIDPattern).WithArgs(int64(1), int64(5)).WillReturnRows(activeMaterialRows(5))
mock.ExpectQuery(orderWarehousePattern).WithArgs(int64(1)).WillReturnRows(emptyWarehouseSummary())
w := newStockRequest(t, "POST", "/api/inbound", inboundBody, 1)
if w.Code != 409 || !strings.Contains(w.Body.String(), "请先配置本公司仓库") {
t.Fatalf("status=%d body=%s, want 409 with 配置提示", w.Code, w.Body.String())
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
})
t.Run("outbound", func(t *testing.T) {
mock := contextMock(t)
expectContextPrincipal(mock, "warehouse_admin")
expectMaterialOrgAccess(mock, 1)
// 出库先解析库存维度:公司无仓库直接 409,不会再校验明细。
mock.ExpectQuery(orderWarehousePattern).WithArgs(int64(1)).WillReturnRows(emptyWarehouseSummary())
w := newStockRequest(t, "POST", "/api/outbound", outboundBody, 1)
if w.Code != 409 || !strings.Contains(w.Body.String(), "请先配置本公司仓库") {
t.Fatalf("status=%d body=%s, want 409 with 配置提示", w.Code, w.Body.String())
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
})
}
// TestBroadcastStockUpdateCarriesOrganizationID:库存事件带上公司 ID,供 Task 5D 按公司投递。
func TestBroadcastStockUpdateCarriesOrganizationID(t *testing.T) {
hub := newHub(nil)
hub.BroadcastStockUpdate(7, "inbound", 3, nil)
select {
case msg := <-hub.broadcast:
if msg.organizationID != 7 || !strings.Contains(string(msg.payload), `"organization_id":7`) {
t.Fatalf("事件未携带公司 ID: org=%d payload=%s", msg.organizationID, string(msg.payload))
}
default:
t.Fatal("没有广播任何事件")
}
}