245 lines
9.5 KiB
Go
245 lines
9.5 KiB
Go
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 的物料返回 404,不泄露别家公司的入出库记录。
|
||
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)
|
||
// 归属校验命中空行后,不再查询历史记录。
|
||
mock.ExpectQuery(`SELECT EXISTS\(SELECT 1 FROM materials WHERE organization_id = \? AND id = \?\)`).WithArgs(int64(1), int64(102)).WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(false))
|
||
|
||
w := newStockRequest(t, "GET", tc.path, "", 1)
|
||
if w.Code != 404 {
|
||
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("没有广播任何事件")
|
||
}
|
||
}
|