132 lines
5.2 KiB
Go
132 lines
5.2 KiB
Go
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(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)
|
||
}
|
||
}
|