material_manage_system/internal/handler/order_organization_test.go

245 lines
9.5 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 的物料返回 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("没有广播任何事件")
}
}