material_manage_system/internal/handler/material_organization_test.go
2026-10-09 10:38:02 +08:00

283 lines
12 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 (
"database/sql/driver"
"errors"
"net/http/httptest"
"strconv"
"strings"
"testing"
"time"
"github.com/DATA-DOG/go-sqlmock"
"github.com/gin-gonic/gin"
)
// 本文件验收 Task 4 的两条硬指标:
// 1) 同一接口在公司 A/B 下只读写各自的数据;跨公司读写返回 404 且不落库;
// 2) A 引用 B 的分类、单位、分类父级、规则分类一律 400,请求体伪造 organization_id
// 不能改变落库公司,非法请求不消费编码号段。
// 中间件链:AuthMiddleware(按 user_id 现查有效角色)→ OrganizationMiddleware(现查公司授权)
// → 业务 Handler。所有 SQL 期望都必须带对组织参数,参数对不上 sqlmock 直接报错。
const (
// 以下是与 repository/service 里 SQL 字面量对得上的稳定子串(sqlmock 用正则匹配查询)。
materialListPattern = `WHERE organization_id = \? AND status = \? ORDER BY id DESC LIMIT \? OFFSET \?`
materialCountPattern = `SELECT COUNT\(\*\) FROM materials WHERE organization_id = \? AND status = \?`
materialGetByIDPattern = `FROM materials WHERE organization_id = \? AND id = \?`
materialInsertPattern = `INSERT INTO materials \(organization_id, material_code`
materialLogInsertPattern = `INSERT INTO material_logs`
lockCodeRulePattern = `FROM code_rules WHERE organization_id = \? AND category_id = \?`
activeSubcategoryPattern = `SELECT 1 FROM material_categories`
activeUnitPattern = `SELECT 1 FROM units`
orgAccessPattern = `SELECT EXISTS\(SELECT 1 FROM organizations`
findCategoryPattern = `SELECT id, organization_id, code, name, description, parent_id, status FROM material_categories`
)
// materialRow 构造单行结果集;不传值表示空结果集(用于"查不到"的路径)。
func materialRow(columns []string, values ...driver.Value) *sqlmock.Rows {
rows := sqlmock.NewRows(columns)
if len(values) > 0 {
rows.AddRow(values...)
}
return rows
}
func materialColumns() []string {
return []string{"id", "organization_id", "material_code", "name", "material_image", "spec", "manufacturer_model",
"category_id", "base_unit_id", "unit", "status", "version", "created_at", "updated_at"}
}
// expectMaterialOrgAccess 让 OrganizationMiddleware 的 CanAccess 放行指定公司。
func expectMaterialOrgAccess(mock sqlmock.Sqlmock, organizationID int64) {
mock.ExpectQuery(orgAccessPattern).
WithArgs(organizationID, int64(7), int64(7)).
WillReturnRows(materialRow([]string{"allowed"}, true))
}
func newMaterialRouter() *gin.Engine {
gin.SetMode(gin.TestMode)
router := gin.New()
NewHandler().RegisterRoutes(router)
return router
}
func newMaterialRequest(t *testing.T, method, path, body string, organizationID int64) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(method, path, strings.NewReader(body))
req.Header.Set("Authorization", "Bearer "+contextToken(t))
req.Header.Set("X-Organization-ID", strconv.FormatInt(organizationID, 10))
if body != "" {
req.Header.Set("Content-Type", "application/json")
}
w := httptest.NewRecorder()
newMaterialRouter().ServeHTTP(w, req)
return w
}
// TestMaterialListScopesToRequestedOrganization:分页总数与列表查询都带请求头里的公司,
// A 与 B 各自只看到本项目返回值,证明筛选、总数没有跨公司汇总。
func TestMaterialListScopesToRequestedOrganization(t *testing.T) {
for _, tc := range []struct {
name string
orgID int64
wantCode string
}{
{"company A", 1, "ORG1-001"},
{"company B", 2, "ORG2-001"},
} {
t.Run(tc.name, func(t *testing.T) {
mock := contextMock(t)
expectContextPrincipal(mock, "warehouse_admin")
expectMaterialOrgAccess(mock, tc.orgID)
mock.ExpectQuery(materialCountPattern).WithArgs(tc.orgID, "active").
WillReturnRows(materialRow([]string{"count"}, int64(1)))
mock.ExpectQuery(materialListPattern).WithArgs(tc.orgID, "active", 20, 0).
WillReturnRows(materialRow(materialColumns(),
int64(7), tc.orgID, tc.wantCode, "轴承", nil, "", nil, int64(12), nil, "pcs", "active", 1, time.Now(), time.Now()))
w := newMaterialRequest(t, "GET", "/api/materials", "", tc.orgID)
if w.Code != 200 || !strings.Contains(w.Body.String(), tc.wantCode) {
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
})
}
}
// TestMaterialCrossOrganizationReadWriteReturnsNotFound:在公司 A 的上下文里操作 B 的物料,
// 读取阶段就因组织条件命中 sql.ErrNoRows,接口回 404,且没有任何写语句被执行。
func TestMaterialCrossOrganizationReadWriteReturnsNotFound(t *testing.T) {
for _, tc := range []struct {
name, method, path, body string
}{
{"detail", "GET", "/api/materials/5", ""},
{"update", "PUT", "/api/materials/5", `{"category_id":12,"name":"改名"}`},
{"obsolete", "POST", "/api/materials/5/obsolete", ""},
{"restore", "POST", "/api/materials/5/restore", ""},
} {
t.Run(tc.name, func(t *testing.T) {
mock := contextMock(t)
expectContextPrincipal(mock, "warehouse_admin")
expectMaterialOrgAccess(mock, 1)
// 物料 5 属于公司 2:带 organization_id=1 的读取查不到。
mock.ExpectQuery(materialGetByIDPattern).WithArgs(int64(1), int64(5)).
WillReturnRows(materialRow(materialColumns()))
w := newMaterialRequest(t, tc.method, tc.path, tc.body, 1)
if w.Code != 404 {
t.Fatalf("跨公司 %s 应当返回 404,实际 status=%d body=%s", tc.method, w.Code, w.Body.String())
}
// 只登记了读取查询:若代码继续执行 UPDATE/DELETE,sqlmock 会因未预期的语句失败。
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
})
}
}
// TestCreateMaterialUsesHeaderOrganizationInsteadOfBody:请求体伪造 organization_id=2,
// 但带 X-Organization-ID=1,落库公司必须取 1;分类校验也针对公司 1。
func TestCreateMaterialUsesHeaderOrganizationInsteadOfBody(t *testing.T) {
mock := contextMock(t)
expectContextPrincipal(mock, "warehouse_admin")
expectMaterialOrgAccess(mock, 1)
mock.ExpectQuery(activeSubcategoryPattern).WithArgs(int64(12), int64(1)).
WillReturnRows(materialRow([]string{"exists"}, true))
mock.ExpectBegin()
mock.ExpectQuery(lockCodeRulePattern).WithArgs(int64(1), int64(12)).
WillReturnRows(materialRow([]string{"id", "organization_id", "category_id", "prefix", "sequence_length", "next_sequence"}))
mock.ExpectExec(materialInsertPattern).
WithArgs(int64(1), "HDR-001", "轴承", nil, "", nil, int64(12), nil, "pcs", "active", 1).
WillReturnResult(sqlmock.NewResult(7, 1))
mock.ExpectCommit()
mock.ExpectQuery(materialGetByIDPattern).WithArgs(int64(1), int64(7)).
WillReturnRows(materialRow(materialColumns(),
int64(7), int64(1), "HDR-001", "轴承", nil, "", nil, int64(12), nil, "pcs", "active", 1, time.Now(), time.Now()))
mock.ExpectExec(materialLogInsertPattern).
WithArgs("CREATE", int64(7), "", sqlmock.AnyArg(), "operator", sqlmock.AnyArg()).
WillReturnResult(sqlmock.NewResult(1, 1))
body := `{"organization_id":2,"material_code":"HDR-001","name":"轴承","category_id":12}`
w := newMaterialRequest(t, "POST", "/api/materials", body, 1)
if w.Code != 201 {
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
}
if !strings.Contains(w.Body.String(), `"organization_id":1`) || strings.Contains(w.Body.String(), `"organization_id":2`) {
t.Fatalf("落库公司必须是请求头里的公司 1,实际 body=%s", w.Body.String())
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
}
// TestCreateMaterialRejectsAnotherOrganizationReferences:A 引用 B 的小类或单位一律 400,
// 且校验失败发生在事务之前,编码号段(code_rules)不会被锁定或推进。
func TestCreateMaterialRejectsAnotherOrganizationReferences(t *testing.T) {
for _, tc := range []struct {
name string
body string
categoryID int64
categoryOK bool
unitID int64
}{
{"category from another company", `{"material_code":"M-1","name":"轴承","category_id":99}`, 99, false, 0},
{"unit from another company", `{"material_code":"M-1","name":"轴承","category_id":12,"base_unit_id":55}`, 12, true, 55},
} {
t.Run(tc.name, func(t *testing.T) {
mock := contextMock(t)
expectContextPrincipal(mock, "warehouse_admin")
expectMaterialOrgAccess(mock, 1)
mock.ExpectQuery(activeSubcategoryPattern).WithArgs(tc.categoryID, int64(1)).
WillReturnRows(materialRow([]string{"exists"}, tc.categoryOK))
if tc.unitID > 0 {
mock.ExpectQuery(activeUnitPattern).WithArgs(tc.unitID, int64(1)).
WillReturnRows(materialRow([]string{"exists"}, false))
}
w := newMaterialRequest(t, "POST", "/api/materials", tc.body, 1)
if w.Code != 400 {
t.Fatalf("跨公司引用应当返回 400,实际 status=%d body=%s", w.Code, w.Body.String())
}
// 没有登记 Begin/insert:一旦越过校验去开事务或写库,sqlmock 会因为
// 未预期的语句报错,断言 400 就同时证明了号段未被消费。
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
})
}
}
// TestCategoryAndCodeRuleRejectCrossOrganizationReference:分类父级与编码规则引用的分类
// 都必须落在当前公司;引用别家公司的 id 一律 400。
func TestCategoryAndCodeRuleRejectCrossOrganizationReference(t *testing.T) {
for _, tc := range []struct {
name, path, body string
categoryID int64
}{
{"category parent", "/api/material-categories", `{"code":"A1","name":"分类","parent_id":77}`, 77},
{"code rule category", "/api/code-rules", `{"category_id":2,"prefix":"B","sequence_length":4}`, 2},
} {
t.Run(tc.name, func(t *testing.T) {
mock := contextMock(t)
expectContextPrincipal(mock, "super_admin")
expectMaterialOrgAccess(mock, 1)
// FindCategory 只在当前公司内查:查不到即视为跨公司引用。
mock.ExpectQuery(findCategoryPattern).WithArgs(tc.categoryID, int64(1)).
WillReturnRows(materialRow([]string{"id", "organization_id", "code", "name", "description", "parent_id", "status"}))
w := newMaterialRequest(t, "POST", tc.path, tc.body, 1)
if w.Code != 400 {
t.Fatalf("跨公司引用应当返回 400,实际 status=%d body=%s", w.Code, w.Body.String())
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
})
}
}
// TestWarehouseAndLocationUniqueConstraintReturnsConflict:每公司一个仓库、每仓库一个库位
// 由数据库唯一约束保证。撞约束时接口必须回 409 并给出计划指定的可操作文案,而不是笼统 400。
func TestWarehouseAndLocationUniqueConstraintReturnsConflict(t *testing.T) {
for _, tc := range []struct {
name, path, body, sql, message string
}{
{
name: "second warehouse",
path: "/api/warehouses",
body: `{"code":"WH2","name":"二号仓"}`,
sql: `INSERT INTO warehouses`,
message: "本公司已配置仓库,请维护现有仓库",
},
{
name: "second location",
path: "/api/warehouses/3/locations",
body: `{"code":"L2","name":"库位二"}`,
sql: `INSERT INTO warehouse_locations`,
message: "本仓库已配置库位,请维护现有库位",
},
} {
t.Run(tc.name, func(t *testing.T) {
mock := contextMock(t)
expectContextPrincipal(mock, "super_admin")
expectMaterialOrgAccess(mock, 1)
mock.ExpectExec(tc.sql).
WillReturnError(errors.New("Error 1062: Duplicate entry '1' for key 'uk_unique'"))
w := newMaterialRequest(t, "POST", tc.path, tc.body, 1)
if w.Code != 409 {
t.Fatalf("唯一约束冲突应当返回 409,实际 status=%d body=%s", w.Code, w.Body.String())
}
if !strings.Contains(w.Body.String(), tc.message) {
t.Fatalf("应回计划指定的中文提示 %q,实际 body=%s", tc.message, w.Body.String())
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatal(err)
}
})
}
}