package handler import ( "net/http/httptest" "strconv" "strings" "testing" "github.com/DATA-DOG/go-sqlmock" "github.com/gin-gonic/gin" ) // 本文件验收 Task 5A 的库存接口隔离: // 1) 库存列表、详情的 SQL 都带请求头公司,公司 A 凭物料 ID 读不到公司 B 的库存(404); // 2) 改变库存域配置的写请求(预警调整)在公司未配置启用仓库、库位时回 409。 // 中间件链:AuthMiddleware(按 user_id 现查有效角色)→ OrganizationMiddleware(现查公司授权) // → 业务 Handler,所有 SQL 期望都必须带对组织参数。 // 与 repository/stock.go 的查询字面量对得上的稳定子串。 const ( stockByMaterialPattern = `FROM materials m.*WHERE m\.organization_id = \? AND m\.id = \?` stockCountPattern = `SELECT COUNT\(\*\) FROM materials m.*WHERE m\.organization_id = \?` stockListPattern = `SELECT m\.id, m\.material_code.*ORDER BY m\.id LIMIT \? OFFSET \?` stockWarehousePattern = `SELECT w.id, w.organization_id` ) func stockMaterialColumns() []string { return []string{"id", "material_code", "name", "spec", "unit", "on_hand", "minimum_available", "enabled", "warning_configured", "major_category", "minor_category"} } func warehouseSummaryColumns() []string { return []string{"id", "organization_id", "code", "name", "status", "location_id", "location_code", "location_name", "location_status"} } func newStockRequest(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() gin.SetMode(gin.TestMode) router := gin.New() NewHandler().RegisterRoutes(router) router.ServeHTTP(w, req) return w } // TestStockDetailScopesToOrganization:库存详情按请求公司过滤,只有本公司物料能读到; // 传别家公司的物料 ID 在读取阶段就查不到,接口回 404,而不是泄露别家公司的余额。 func TestStockDetailScopesToOrganization(t *testing.T) { for _, tc := range []struct { name string material int64 found bool wantCode int }{ {"own material", 5, true, 200}, {"another company's material", 7, false, 404}, } { t.Run(tc.name, func(t *testing.T) { mock := contextMock(t) expectContextPrincipal(mock, "warehouse_admin") expectMaterialOrgAccess(mock, 1) rows := sqlmock.NewRows(stockMaterialColumns()) if tc.found { rows.AddRow(5, "MAT-005", "轴承", "6204", "个", "12.000000", "3.000000", true, true, "A01", "A01-01") } mock.ExpectQuery(`(?s)`+stockByMaterialPattern).WithArgs(int64(1), tc.material). WillReturnRows(rows) w := newStockRequest(t, "GET", "/api/stock/"+strconv.FormatInt(tc.material, 10), "", 1) if w.Code != tc.wantCode { t.Fatalf("status=%d body=%s, want %d", w.Code, w.Body.String(), tc.wantCode) } if tc.found && !strings.Contains(w.Body.String(), "MAT-005") { t.Fatalf("expected own material in body, got %s", w.Body.String()) } if err := mock.ExpectationsWereMet(); err != nil { t.Fatal(err) } }) } } // TestStockListScopesToOrganization:列表的 COUNT 与主查询都带请求公司,分页总数和 // 实际行不会跨公司汇总。 func TestStockListScopesToOrganization(t *testing.T) { mock := contextMock(t) expectContextPrincipal(mock, "warehouse_admin") expectMaterialOrgAccess(mock, 2) mock.ExpectQuery(`(?s)` + stockCountPattern).WithArgs(int64(2)). WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1)) mock.ExpectQuery(`(?s)`+stockListPattern).WithArgs(int64(2), 20, 0). WillReturnRows(sqlmock.NewRows(stockMaterialColumns()). AddRow(9, "ORG2-009", "电机", "80W", "台", "4.000000", "0.000000", true, true, "B01", "B01-01")) w := newStockRequest(t, "GET", "/api/stock", "", 2) if w.Code != 200 || !strings.Contains(w.Body.String(), "ORG2-009") { t.Fatalf("status=%d body=%s", w.Code, w.Body.String()) } if err := mock.ExpectationsWereMet(); err != nil { t.Fatal(err) } } // TestStockWarningWriteReturnsConflictWhenScopeUnavailable:预警调整是改变库存域的写操作, // 公司没有启用仓库时必须回 409 并给出计划指定的配置提示,不能写进默认公司。 func TestStockWarningWriteReturnsConflictWhenScopeUnavailable(t *testing.T) { mock := contextMock(t) expectContextPrincipal(mock, "warehouse_admin") expectMaterialOrgAccess(mock, 1) // 物料属于本公司(先通过归属校验),但公司没有仓库:Summary 返回 nil。 mock.ExpectQuery(`(?s)`+stockByMaterialPattern).WithArgs(int64(1), int64(5)). WillReturnRows(sqlmock.NewRows(stockMaterialColumns()). AddRow(5, "MAT-005", "轴承", "6204", "个", "0.000000", "0.000000", true, true, "A01", "A01-01")) mock.ExpectQuery(stockWarehousePattern).WithArgs(int64(1)). WillReturnRows(sqlmock.NewRows(warehouseSummaryColumns())) w := newStockRequest(t, "PUT", "/api/stock/5/warning", `{"warning_stock":10}`, 1) if w.Code != 409 { t.Fatalf("未配置仓库的写操作应当返回 409,实际 status=%d body=%s", w.Code, w.Body.String()) } if !strings.Contains(w.Body.String(), "请先配置本公司仓库") { t.Fatalf("应回具体配置提示,实际 body=%s", w.Body.String()) } if err := mock.ExpectationsWereMet(); err != nil { t.Fatal(err) } } // TestStockWarningWriteRejectsAnotherOrganizationsMaterial:预警规则的 material_id 外键指向 // 全局 materials(id),写别家公司的物料必须在归属校验阶段回 404,且不执行任何 upsert。 func TestStockWarningWriteRejectsAnotherOrganizationsMaterial(t *testing.T) { mock := contextMock(t) expectContextPrincipal(mock, "warehouse_admin") expectMaterialOrgAccess(mock, 1) // 物料 7 属于公司 2:带 organization_id=1 的读取查不到。 mock.ExpectQuery(`(?s)`+stockByMaterialPattern).WithArgs(int64(1), int64(7)). WillReturnRows(sqlmock.NewRows(stockMaterialColumns())) w := newStockRequest(t, "PUT", "/api/stock/7/warning", `{"warning_stock":10}`, 1) if w.Code != 404 { t.Fatalf("跨公司物料写预警应当返回 404,实际 status=%d body=%s", w.Code, w.Body.String()) } // 只登记了归属校验查询:一旦越过它去 upsert,sqlmock 会因未预期语句报错。 if err := mock.ExpectationsWereMet(); err != nil { t.Fatal(err) } }