package handler import ( "strconv" "strings" "testing" "time" "github.com/DATA-DOG/go-sqlmock" ) // 本文件验收 Task 5B 的入出库单据接口隔离: // 1. 单据详情、按物料历史、编辑、删除都带请求头公司,A 读/改/删 B 的单据返回 404; // 2. 未配置启用仓库、库位时,改变库存的新建请求返回 409 与具体配置提示。 // // 中间件链:AuthMiddleware → OrganizationMiddleware → 业务 Handler,所有 SQL 期望 // 都必须带对组织参数,参数对不上 sqlmock 直接报错。 // // 与 repository 里的查询字面量对得上的稳定子串。 const ( inboundHeadPattern = `SELECT id, COALESCE\(inbound_no` outboundHeadPattern = `SELECT id, COALESCE\(outbound_no` inboundByMatPattern = `SELECT ii.id, ii.inbound_order_id` outboundByMatPattern = `SELECT oi.id, oi.outbound_order_id` orderWarehousePattern = `SELECT w.id, w.organization_id` ) func orderHeadRow(prefix string, id, organizationID int64) *sqlmock.Rows { now := time.Now() return sqlmock.NewRows([]string{"id", "order_no", "operator", "order_date", "handler", "party", "remark", "organization_id", "warehouse_id", "location_id", "created_at", "updated_at"}). AddRow(id, prefix+strconv.FormatInt(id, 10), "", now, "h", "p", "", organizationID, 10, 20, now, now) } func emptyOrderHeadRows() *sqlmock.Rows { return sqlmock.NewRows([]string{"id", "order_no", "operator", "order_date", "handler", "party", "remark", "organization_id", "warehouse_id", "location_id", "created_at", "updated_at"}) } func emptyInboundRecords() *sqlmock.Rows { return sqlmock.NewRows([]string{"id", "inbound_id", "handler", "supplier", "quantity", "created_at"}) } func emptyOutboundRecords() *sqlmock.Rows { return sqlmock.NewRows([]string{"id", "outbound_id", "handler", "receiver", "quantity", "created_at"}) } func emptyOrderItems() *sqlmock.Rows { return sqlmock.NewRows([]string{"id", "order_id", "material_id", "quantity", "material_code", "name", "status"}) } func emptyWarehouseSummary() *sqlmock.Rows { return sqlmock.NewRows([]string{"id", "organization_id", "code", "name", "status", "location_id", "location_code", "location_name", "location_status"}) } func activeMaterialRows(id int64) *sqlmock.Rows { now := time.Now() return sqlmock.NewRows(materialColumns()). AddRow(id, 1, "MAT-005", "轴承", nil, "", nil, 1, 1, "个", "active", 1, now, now) } // TestOrderDetailScopesToOrganization:单据详情按请求公司过滤,传别家公司的单据 ID 读不到,回 404。 func TestOrderDetailScopesToOrganization(t *testing.T) { for _, tc := range []struct { name string id int64 path string head string items string found bool prefix string wantCode int }{ {"inbound own", 101, "/api/inbound/101", inboundHeadPattern, inboundByMatPattern, true, "IN-", 200}, {"inbound cross", 102, "/api/inbound/102", inboundHeadPattern, inboundByMatPattern, false, "IN-", 404}, {"outbound own", 101, "/api/outbound/101", outboundHeadPattern, outboundByMatPattern, true, "OUT-", 200}, {"outbound cross", 102, "/api/outbound/102", outboundHeadPattern, outboundByMatPattern, false, "OUT-", 404}, } { t.Run(tc.name, func(t *testing.T) { mock := contextMock(t) expectContextPrincipal(mock, "warehouse_admin") expectMaterialOrgAccess(mock, 1) headRows := emptyOrderHeadRows() if tc.found { headRows = orderHeadRow(tc.prefix, tc.id, 1) } mock.ExpectQuery(tc.head).WithArgs(tc.id, int64(1)).WillReturnRows(headRows) if tc.found { mock.ExpectQuery(tc.items).WithArgs(tc.id).WillReturnRows(emptyOrderItems()) } w := newStockRequest(t, "GET", tc.path, "", 1) if w.Code != tc.wantCode { t.Fatalf("status=%d body=%s, want %d", w.Code, w.Body.String(), tc.wantCode) } if err := mock.ExpectationsWereMet(); err != nil { t.Fatal(err) } }) } } // TestOrderByMaterialScopesToOrganization:按物料历史记录带请求公司查询, // A 上下文读 B 的物料返回 404,不泄露别家公司的入出库记录。 func TestOrderByMaterialScopesToOrganization(t *testing.T) { for _, tc := range []struct { name string path string pattern string rows func() *sqlmock.Rows }{ {"inbound by material", "/api/inbound/materials/102", inboundByMatPattern, emptyInboundRecords}, {"outbound by material", "/api/outbound/materials/102", outboundByMatPattern, emptyOutboundRecords}, } { t.Run(tc.name, func(t *testing.T) { mock := contextMock(t) expectContextPrincipal(mock, "warehouse_admin") expectMaterialOrgAccess(mock, 1) // 归属校验命中空行后,不再查询历史记录。 mock.ExpectQuery(`SELECT EXISTS\(SELECT 1 FROM materials WHERE organization_id = \? AND id = \?\)`).WithArgs(int64(1), int64(102)).WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(false)) w := newStockRequest(t, "GET", tc.path, "", 1) if w.Code != 404 { t.Fatalf("status=%d body=%s", w.Code, w.Body.String()) } if err := mock.ExpectationsWereMet(); err != nil { t.Fatal(err) } }) } } // TestOrderDeleteCrossOrganizationReturnsNotFound:删除别家公司的单据时,事务内锁定 // 当前公司原单据命中 ErrNoRows,接口回 404,且不执行任何冲销。 func TestOrderDeleteCrossOrganizationReturnsNotFound(t *testing.T) { for _, tc := range []struct { name string path string pattern string }{ {"inbound", "/api/inbound/102", inboundHeadPattern}, {"outbound", "/api/outbound/102", outboundHeadPattern}, } { t.Run(tc.name, func(t *testing.T) { mock := contextMock(t) expectContextPrincipal(mock, "warehouse_admin") expectMaterialOrgAccess(mock, 1) mock.ExpectBegin() mock.ExpectQuery(tc.pattern).WithArgs(int64(102), int64(1)).WillReturnRows(emptyOrderHeadRows()) mock.ExpectRollback() w := newStockRequest(t, "DELETE", tc.path, "", 1) if w.Code != 404 { t.Fatalf("status=%d body=%s, want 404", w.Code, w.Body.String()) } if err := mock.ExpectationsWereMet(); err != nil { t.Fatal(err) } }) } } // TestOrderUpdateCrossOrganizationReturnsNotFound:编辑别家公司的单据时,读取原单据 // 命中 ErrNoRows,接口回 404,不落任何改动。 func TestOrderUpdateCrossOrganizationReturnsNotFound(t *testing.T) { body := `{"items":[{"material_id":7,"quantity":1}]}` for _, tc := range []struct { name string path string pattern string }{ {"inbound", "/api/inbound/102", inboundHeadPattern}, {"outbound", "/api/outbound/102", outboundHeadPattern}, } { t.Run(tc.name, func(t *testing.T) { mock := contextMock(t) expectContextPrincipal(mock, "warehouse_admin") expectMaterialOrgAccess(mock, 1) mock.ExpectQuery(tc.pattern).WithArgs(int64(102), int64(1)).WillReturnRows(emptyOrderHeadRows()) w := newStockRequest(t, "PUT", tc.path, body, 1) if w.Code != 404 { t.Fatalf("status=%d body=%s, want 404", w.Code, w.Body.String()) } // 只登记了归属读取:一旦越过它去改明细,sqlmock 会因未预期语句报错。 if err := mock.ExpectationsWereMet(); err != nil { t.Fatal(err) } }) } } // TestCreateOrderWithoutWarehouseReturnsConflict:改变库存的新建请求在公司未配置 // 启用仓库、库位时回 409 与具体配置提示,不回退默认公司。 func TestCreateOrderWithoutWarehouseReturnsConflict(t *testing.T) { inboundBody := `{"handler":"h","supplier":"s","items":[{"material_id":5,"quantity":1}]}` outboundBody := `{"receiver":"r","items":[{"material_id":5,"quantity":1}]}` t.Run("inbound", func(t *testing.T) { mock := contextMock(t) expectContextPrincipal(mock, "warehouse_admin") expectMaterialOrgAccess(mock, 1) // 入库先校验明细物料,再解析库存维度;物料合法但公司无仓库 → 409。 mock.ExpectQuery(materialGetByIDPattern).WithArgs(int64(1), int64(5)).WillReturnRows(activeMaterialRows(5)) mock.ExpectQuery(materialGetByIDPattern).WithArgs(int64(1), int64(5)).WillReturnRows(activeMaterialRows(5)) mock.ExpectQuery(orderWarehousePattern).WithArgs(int64(1)).WillReturnRows(emptyWarehouseSummary()) w := newStockRequest(t, "POST", "/api/inbound", inboundBody, 1) if w.Code != 409 || !strings.Contains(w.Body.String(), "请先配置本公司仓库") { t.Fatalf("status=%d body=%s, want 409 with 配置提示", w.Code, w.Body.String()) } if err := mock.ExpectationsWereMet(); err != nil { t.Fatal(err) } }) t.Run("outbound", func(t *testing.T) { mock := contextMock(t) expectContextPrincipal(mock, "warehouse_admin") expectMaterialOrgAccess(mock, 1) // 出库先解析库存维度:公司无仓库直接 409,不会再校验明细。 mock.ExpectQuery(orderWarehousePattern).WithArgs(int64(1)).WillReturnRows(emptyWarehouseSummary()) w := newStockRequest(t, "POST", "/api/outbound", outboundBody, 1) if w.Code != 409 || !strings.Contains(w.Body.String(), "请先配置本公司仓库") { t.Fatalf("status=%d body=%s, want 409 with 配置提示", w.Code, w.Body.String()) } if err := mock.ExpectationsWereMet(); err != nil { t.Fatal(err) } }) } // TestBroadcastStockUpdateCarriesOrganizationID:库存事件带上公司 ID,供 Task 5D 按公司投递。 func TestBroadcastStockUpdateCarriesOrganizationID(t *testing.T) { hub := newHub(nil) hub.BroadcastStockUpdate(7, "inbound", 3, nil) select { case msg := <-hub.broadcast: if msg.organizationID != 7 || !strings.Contains(string(msg.payload), `"organization_id":7`) { t.Fatalf("事件未携带公司 ID: org=%d payload=%s", msg.organizationID, string(msg.payload)) } default: t.Fatal("没有广播任何事件") } }