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) } }) } }