- 物料默认单位由 pcs 改为「个」,新建/编辑表单与后端 Create 保持一致 - 单位下拉增加未选择时的默认提示 - init.sql 补充 30 个常用计量单位种子数据 - init.sql 新增 material_import_logs 表,为批量导入提供落库 - 新增物料批量导入实施计划文档,相关测试期望值同步更新
283 lines
12 KiB
Go
283 lines
12 KiB
Go
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, "个", "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, "个", "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)
|
||
}
|
||
})
|
||
}
|
||
}
|