material_manage_system/internal/handler/stock_organization_test.go

155 lines
6.4 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 (
"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)
}
}