diff --git a/backend/api/internal/logic/platform/resource.go b/backend/api/internal/logic/platform/resource.go index ebe1489..fca350f 100644 --- a/backend/api/internal/logic/platform/resource.go +++ b/backend/api/internal/logic/platform/resource.go @@ -80,16 +80,16 @@ func ExpectedResources() []ResourceContract { {Domain: "user", Name: "user_service_relation", Mode: Writable, PageKind: "list"}, {Domain: "device", Name: "dev_smart_cylinder_valve", Mode: Writable, PageKind: "list"}, {Domain: "device", Name: "dev_device_binding", Mode: Writable, PageKind: "list"}, - {Domain: "device", Name: "saf_rule", Mode: Writable, PageKind: "list"}, - {Domain: "device", Name: "saf_event", Mode: Writable, PageKind: "list"}, - {Domain: "device", Name: "saf_inspection", Mode: Writable, PageKind: "list"}, - {Domain: "commerce", Name: "ec_category", Mode: Writable, PageKind: "list"}, - {Domain: "commerce", Name: "ec_product", Mode: Writable, PageKind: "list"}, - {Domain: "commerce", Name: "ec_product_attribute", Mode: Writable, PageKind: "list"}, - {Domain: "commerce", Name: "ec_product_image", Mode: Writable, PageKind: "list"}, - {Domain: "commerce", Name: "ec_cart", Mode: Writable, PageKind: "list"}, - {Domain: "commerce", Name: "ec_order", Mode: Writable, PageKind: "list"}, - {Domain: "commerce", Name: "ec_review", Mode: Writable, PageKind: "list"}, + {Domain: "safety", Name: "saf_rule", Mode: Writable, PageKind: "list"}, + {Domain: "safety", Name: "saf_event", Mode: Writable, PageKind: "list"}, + {Domain: "safety", Name: "saf_inspection", Mode: Writable, PageKind: "list"}, + {Domain: "ec", Name: "ec_category", Mode: Writable, PageKind: "list"}, + {Domain: "ec", Name: "ec_product", Mode: Writable, PageKind: "list"}, + {Domain: "ec", Name: "ec_product_attribute", Mode: Writable, PageKind: "list"}, + {Domain: "ec", Name: "ec_product_image", Mode: Writable, PageKind: "list"}, + {Domain: "ec", Name: "ec_cart", Mode: Writable, PageKind: "list"}, + {Domain: "ec", Name: "ec_order", Mode: Writable, PageKind: "list"}, + {Domain: "ec", Name: "ec_review", Mode: Writable, PageKind: "list"}, {Domain: "delivery", Name: "delivery_task", Mode: Writable, PageKind: "list"}, {Domain: "delivery", Name: "delivery_track", Mode: Writable, PageKind: "list"}, {Domain: "delivery", Name: "delivery_track_point", Mode: Writable, PageKind: "list"}, @@ -113,6 +113,6 @@ func ExpectedResources() []ResourceContract { {Domain: "audit", Name: "aud_operation_log", Mode: ReadOnly, PageKind: "list"}, {Domain: "audit", Name: "aud_export_log", Mode: ReadOnly, PageKind: "list"}, {Domain: "audit", Name: "aud_approval", Mode: ReadOnly, PageKind: "list"}, - {Domain: "device", Name: "saf_event_disposal", Mode: AppendOnly, PageKind: "list"}, + {Domain: "safety", Name: "saf_event_disposal", Mode: AppendOnly, PageKind: "list"}, } } diff --git a/backend/api/internal/logic/platform/resource_test.go b/backend/api/internal/logic/platform/resource_test.go index e2850df..0457c85 100644 --- a/backend/api/internal/logic/platform/resource_test.go +++ b/backend/api/internal/logic/platform/resource_test.go @@ -7,9 +7,12 @@ import ( "net/http" "net/http/httptest" "regexp" + "strings" "testing" + "time" "git.apinb.com/bsm-sdk/core/errcode" + "git.apinb.com/bsm-sdk/core/types" "git.apinb.com/heqiapp/platforms/backend/api/internal/impl" "git.apinb.com/heqiapp/platforms/backend/api/internal/models" "github.com/DATA-DOG/go-sqlmock" @@ -21,7 +24,7 @@ import ( func TestExpectedResources(t *testing.T) { assertContract(t, ExpectedResources(), "gas", "gas_basic", Writable, "list") - assertContract(t, ExpectedResources(), "device", "saf_event", Writable, "list") + assertContract(t, ExpectedResources(), "safety", "saf_event", Writable, "list") assertContract(t, ExpectedResources(), "wallet", "wallet_ledger", ReadOnly, "list") } @@ -159,6 +162,76 @@ func TestPlatformRoleStatusAndArchiveReturnNotFoundWhenUpdateAffectsZeroRows(t * } } +func TestGetEcOrderReturnsOrderItems(t *testing.T) { + _, mock := setupPlatformRoleDatabase(t) + mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "ec_order" WHERE identity = $1 ORDER BY "ec_order"."id" LIMIT $2`)). + WithArgs("order-a", 1). + WillReturnRows(sqlmock.NewRows([]string{"id", "identity", "created_at", "updated_at", "status", "version", "order_no", "user_account_id", "gas_station_id", "delivery_point_id", "total_amount"}). + AddRow(uint64(1), "order-a", nil, nil, "enabled", 1, "O-1", uint64(2), uint64(3), uint64(4), int64(500))) + mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "ec_order_item" WHERE ec_order_id = $1 ORDER BY id asc`)). + WithArgs(uint64(1)). + WillReturnRows(sqlmock.NewRows([]string{"id", "identity", "created_at", "updated_at", "status", "version", "ec_order_id", "ec_product_id", "product_snapshot", "quantity", "sale_amount"}). + AddRow(uint64(2), "item-a", nil, nil, "enabled", 1, uint64(1), uint64(5), `{}`, 2, int64(500))) + + ctx, recorder := updateContext(http.MethodGet, "/ec/ec_order/order-a", "order-a", nil) + GetEcOrder(ctx) + + assertResponseCode(t, recorder, 0) + if !strings.Contains(recorder.Body.String(), `"items"`) || !strings.Contains(recorder.Body.String(), `"item-a"`) { + t.Fatalf("order detail omitted its items: %s", recorder.Body.String()) + } + assertMockExpectations(t, mock) +} + +func TestGetDeliveryTrackOrdersAndMasksPointsWithoutPreciseLocationScope(t *testing.T) { + _, mock := setupPlatformRoleDatabase(t) + now := time.Now().UTC() + mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "delivery_track" WHERE identity = $1 ORDER BY "delivery_track"."id" LIMIT $2`)). + WithArgs("track-a", 1). + WillReturnRows(sqlmock.NewRows([]string{"id", "identity", "created_at", "updated_at", "status", "version", "delivery_task_id", "started_at", "completed_at"}). + AddRow(uint64(7), "track-a", nil, nil, "enabled", 1, uint64(8), now, nil)) + mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "delivery_track_point" WHERE delivery_track_id = $1 ORDER BY occurred_at asc`)). + WithArgs(uint64(7)). + WillReturnRows(sqlmock.NewRows([]string{"id", "identity", "created_at", "updated_at", "status", "version", "delivery_track_id", "point_type", "occurred_at", "longitude", "latitude"}). + AddRow(uint64(9), "point-a", nil, nil, "enabled", 1, uint64(7), "arrival", now, "120.123", "30.456")) + + ctx, recorder := updateContext(http.MethodGet, "/delivery/delivery_track/track-a", "track-a", nil) + GetDeliveryTrack(ctx) + + assertResponseCode(t, recorder, 0) + if strings.Contains(recorder.Body.String(), "120.123") || strings.Contains(recorder.Body.String(), "30.456") { + t.Fatalf("unauthorized response exposed precise coordinates: %s", recorder.Body.String()) + } + assertMockExpectations(t, mock) +} + +func TestDisposeSafetyEventUpdatesEventAndAppendsOperatorActionTransactionally(t *testing.T) { + _, mock := setupPlatformRoleDatabase(t) + now := time.Now().UTC() + mock.ExpectBegin() + mock.ExpectQuery(regexp.QuoteMeta(`SELECT * FROM "saf_event" WHERE identity = $1 ORDER BY "saf_event"."id" LIMIT $2`)). + WithArgs("event-a", 1). + WillReturnRows(sqlmock.NewRows([]string{"id", "identity", "created_at", "updated_at", "status", "version", "event_code", "level", "title", "smart_cylinder_valve_identity", "sla_at"}). + AddRow(uint64(3), "event-a", now, now, "open", 1, "E-1", 2, "alarm", "valve-a", nil)) + mock.ExpectExec(regexp.QuoteMeta(`UPDATE "saf_event" SET "status"=$1,"updated_at"=$2 WHERE identity = $3`)). + WithArgs("disposed", sqlmock.AnyArg(), "event-a"). + WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectQuery(regexp.QuoteMeta(`INSERT INTO "saf_event_disposal" ("identity","created_at","updated_at","status","version","saf_event_identity","action","reason","operator_identity") VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) RETURNING "id"`)). + WithArgs(sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), "enabled", 1, "event-a", "close", "resolved", "operator-a"). + WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(uint64(1))) + mock.ExpectCommit() + + ctx, recorder := updateContext(http.MethodPost, "/safety/saf_event/event-a/disposals", "event-a", []byte(`{"action":"close","reason":"resolved"}`)) + ctx.Set("Auth", &types.JwtClaims{Identity: "operator-a"}) + DisposeSafetyEvent(ctx) + + assertResponseCode(t, recorder, 0) + if !strings.Contains(recorder.Body.String(), `"operator_identity":"operator-a"`) { + t.Fatalf("disposal omitted its operator: %s", recorder.Body.String()) + } + assertMockExpectations(t, mock) +} + func TestReplacePlatformRoleMenusAllowsAnEmptySetToClearAssignmentsTransactionally(t *testing.T) { _, mock := setupPlatformRoleDatabase(t) mock.ExpectBegin() diff --git a/backend/api/internal/logic/platform/task4_resources.go b/backend/api/internal/logic/platform/task4_resources.go new file mode 100644 index 0000000..ab0f7cc --- /dev/null +++ b/backend/api/internal/logic/platform/task4_resources.go @@ -0,0 +1,175 @@ +package platform + +import ( + "encoding/json" + "errors" + "reflect" + + "git.apinb.com/bsm-sdk/core/errcode" + "git.apinb.com/bsm-sdk/core/infra" + "git.apinb.com/bsm-sdk/core/middleware" + "git.apinb.com/heqiapp/platforms/backend/api/internal/impl" + "git.apinb.com/heqiapp/platforms/backend/api/internal/models" + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +// ResourceHandlers supplies the common identity-based CRUD boundary used by +// platform resources whose writable fields are explicitly declared by routes. +func ResourceHandlers(model any, createFields, updateFields []string) (gin.HandlerFunc, gin.HandlerFunc, gin.HandlerFunc, gin.HandlerFunc) { + return func(ctx *gin.Context) { listResource(ctx, model) }, + func(ctx *gin.Context) { createResource(ctx, model, createFields) }, + func(ctx *gin.Context) { getResource(ctx, model) }, + func(ctx *gin.Context) { updateResource(ctx, model, updateFields) } +} + +func listResource(ctx *gin.Context, model any) { + page, size := pageSize(ctx) + list := reflect.New(reflect.SliceOf(reflect.TypeOf(model).Elem())) + var total int64 + query := impl.DBService.Model(model) + if err := query.Count(&total).Error; err != nil { + infra.Response.Error(ctx, err) + return + } + if err := query.Order("created_at desc").Offset((page - 1) * size).Limit(size).Find(list.Interface()).Error; err != nil { + infra.Response.Error(ctx, err) + return + } + infra.Response.Success(ctx, gin.H{"total": total, "list": list.Elem().Interface()}) +} + +func getResource(ctx *gin.Context, model any) { + data := reflect.New(reflect.TypeOf(model).Elem()) + if err := impl.DBService.Where("identity = ?", ctx.Param("identity")).First(data.Interface()).Error; err != nil { + respondRecordError(ctx, err) + return + } + infra.Response.Success(ctx, data.Interface()) +} + +func createResource(ctx *gin.Context, model any, allowedFields []string) { + var input map[string]any + if err := ctx.ShouldBindJSON(&input); err != nil { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + values := filterFields(input, allowedFields) + encoded, err := json.Marshal(values) + if err != nil { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + data := reflect.New(reflect.TypeOf(model).Elem()) + if err := json.Unmarshal(encoded, data.Interface()); err != nil { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + data.Elem().FieldByName("Entity").Set(reflect.ValueOf(newEntity("draft"))) + if err := impl.DBService.Create(data.Interface()).Error; err != nil { + infra.Response.Error(ctx, err) + return + } + infra.Response.Success(ctx, data.Interface()) +} + +func updateResource(ctx *gin.Context, model any, allowedFields []string) { + var input map[string]any + if err := ctx.ShouldBindJSON(&input); err != nil { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + updateAllowedByIdentity(ctx, model, input, allowedFields) +} + +// DisposeSafetyEvent atomically updates an event and appends its operator-owned +// action record. Disposal records deliberately have no update or delete route. +func DisposeSafetyEvent(ctx *gin.Context) { + claims, err := middleware.ParseAuth(ctx) + if err != nil { + infra.Response.Error(ctx, err) + return + } + var request struct { + Action string `json:"action" binding:"required,max=64"` + Reason string `json:"reason" binding:"max=2000"` + Status string `json:"status" binding:"max=32"` + } + if err := ctx.ShouldBindJSON(&request); err != nil { + infra.Response.Error(ctx, errcode.ErrInvalidArgument) + return + } + if request.Status == "" { + request.Status = "disposed" + } + var disposal models.SafEventDisposal + err = impl.DBService.Transaction(func(transaction *gorm.DB) error { + var event models.SafEvent + if err := transaction.Where("identity = ?", ctx.Param("identity")).First(&event).Error; err != nil { + return err + } + if result := transaction.Model(&models.SafEvent{}).Where("identity = ?", event.Identity).Update("status", request.Status); result.Error != nil { + return result.Error + } else if result.RowsAffected == 0 { + return gorm.ErrRecordNotFound + } + disposal = models.SafEventDisposal{ + Entity: newEntity("enabled"), + SafEventIdentity: event.Identity, + Action: request.Action, + Reason: request.Reason, + OperatorIdentity: claims.Identity, + } + return transaction.Create(&disposal).Error + }) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + infra.Response.Error(ctx, errcode.ErrRecordNotFound) + return + } + infra.Response.Error(ctx, err) + return + } + infra.Response.Success(ctx, disposal) +} + +// GetEcOrder returns the order together with its immutable item snapshots. +func GetEcOrder(ctx *gin.Context) { + var order models.EcOrder + if err := impl.DBService.Where("identity = ?", ctx.Param("identity")).First(&order).Error; err != nil { + respondRecordError(ctx, err) + return + } + var items []models.EcOrderItem + if err := impl.DBService.Where("ec_order_id = ?", order.ID).Order("id asc").Find(&items).Error; err != nil { + infra.Response.Error(ctx, err) + return + } + infra.Response.Success(ctx, gin.H{"order": order, "items": items}) +} + +// GetDeliveryTrack returns time-ordered points. Precise coordinates are only +// exposed to tokens explicitly granted the location_scope=precise claim. +func GetDeliveryTrack(ctx *gin.Context) { + var track models.DeliveryTrack + if err := impl.DBService.Where("identity = ?", ctx.Param("identity")).First(&track).Error; err != nil { + respondRecordError(ctx, err) + return + } + var points []models.DeliveryTrackPoint + if err := impl.DBService.Where("delivery_track_id = ?", track.ID).Order("occurred_at asc").Find(&points).Error; err != nil { + infra.Response.Error(ctx, err) + return + } + precise := false + if claims, err := middleware.ParseAuth(ctx); err == nil { + precise = claims.Extend["location_scope"] == "precise" + } + if !precise { + for index := range points { + points[index].Longitude = "" + points[index].Latitude = "" + } + } + infra.Response.Success(ctx, gin.H{"track": track, "points": points}) +} diff --git a/backend/api/internal/routers/platform.go b/backend/api/internal/routers/platform.go index 18f9a54..76babf9 100644 --- a/backend/api/internal/routers/platform.go +++ b/backend/api/internal/routers/platform.go @@ -26,6 +26,9 @@ func RegisterPlatform(serviceKey string, engine *gin.Engine) { registerDeliveryRoute(protected) registerStaffRoute(protected) registerUserRoute(protected) + registerDeviceRoute(protected) + registerSafetyRoute(protected) + registerCommerceRoute(protected) registerPlatformRoute(protected) } @@ -37,6 +40,37 @@ func registerGasRoute(group *gin.RouterGroup) { func registerDeliveryRoute(group *gin.RouterGroup) { registerWritableResource(group, "/delivery/delivery_basic", platform.ListDeliveryBasic, platform.CreateDeliveryBasic, platform.GetDeliveryBasic, platform.UpdateDeliveryBasic, &models.DeliveryBasic{}) registerWritableResource(group, "/delivery/delivery_account", platform.ListDeliveryAccount, platform.CreateDeliveryAccount, platform.GetDeliveryAccount, platform.UpdateDeliveryAccount, &models.DeliveryAccount{}) + registerRestrictedWritableResource(group, "/delivery/delivery_task", &models.DeliveryTask{}, []string{"ec_order_id", "staff_account_id", "delivery_point_id"}) + list, create, _, update := platform.ResourceHandlers(&models.DeliveryTrack{}, []string{"delivery_task_id", "started_at", "completed_at"}, []string{"delivery_task_id", "started_at", "completed_at"}) + registerWritableResource(group, "/delivery/delivery_track", list, create, platform.GetDeliveryTrack, update, &models.DeliveryTrack{}) + registerRestrictedWritableResource(group, "/delivery/delivery_track_point", &models.DeliveryTrackPoint{}, []string{"delivery_track_id", "point_type", "occurred_at", "longitude", "latitude"}) +} + +func registerDeviceRoute(group *gin.RouterGroup) { + registerRestrictedWritableResource(group, "/device/dev_smart_cylinder_valve", &models.DevSmartCylinderValve{}, []string{"device_no", "model", "online_status", "owner_identity"}) + registerRestrictedWritableResource(group, "/device/dev_device_binding", &models.DevDeviceBinding{}, []string{"smart_cylinder_valve_id", "user_account_id", "effective_at", "expired_at"}) + list, _, get, _ := platform.ResourceHandlers(&models.DevTelemetry{}, nil, nil) + telemetry := group.Group("/device/dev_telemetry") + telemetry.GET("", list) + telemetry.GET("/:identity", get) +} + +func registerSafetyRoute(group *gin.RouterGroup) { + registerRestrictedWritableResource(group, "/safety/saf_rule", &models.SafRule{}, []string{"rule_code", "version_no", "threshold", "action", "gray_scope"}) + registerRestrictedWritableResource(group, "/safety/saf_event", &models.SafEvent{}, []string{"event_code", "level", "title", "smart_cylinder_valve_identity", "sla_at"}) + registerRestrictedWritableResource(group, "/safety/saf_inspection", &models.SafInspection{}, []string{"user_account_id", "staff_account_id", "result", "evidence_uri"}) + group.POST("/safety/saf_event/:identity/disposals", platform.DisposeSafetyEvent) +} + +func registerCommerceRoute(group *gin.RouterGroup) { + registerRestrictedWritableResource(group, "/ec/ec_category", &models.EcCategory{}, []string{"parent_id", "name", "sort_no"}) + registerRestrictedWritableResource(group, "/ec/ec_product", &models.EcProduct{}, []string{"ec_category_id", "product_code", "name", "price_amount", "stock_quantity"}) + registerRestrictedWritableResource(group, "/ec/ec_product_attribute", &models.EcProductAttribute{}, []string{"ec_product_id", "name", "value", "sort_no"}) + registerRestrictedWritableResource(group, "/ec/ec_product_image", &models.EcProductImage{}, []string{"ec_product_id", "image_uri", "sort_no", "is_cover"}) + registerRestrictedWritableResource(group, "/ec/ec_cart", &models.EcCart{}, []string{"user_account_id", "ec_product_id", "quantity", "selected"}) + list, create, _, update := platform.ResourceHandlers(&models.EcOrder{}, []string{"order_no", "user_account_id", "gas_station_id", "delivery_point_id", "total_amount"}, []string{"gas_station_id", "delivery_point_id", "total_amount"}) + registerWritableResource(group, "/ec/ec_order", list, create, platform.GetEcOrder, update, &models.EcOrder{}) + registerRestrictedWritableResource(group, "/ec/ec_review", &models.EcReview{}, []string{"ec_order_id", "ec_product_id", "user_account_id", "score", "content"}) } func registerStaffRoute(group *gin.RouterGroup) { @@ -72,3 +106,8 @@ func registerWritableResource(group *gin.RouterGroup, path string, list, create, resource.PATCH("/:identity/status", func(ctx *gin.Context) { platform.UpdateRecordStatus(ctx, model) }) resource.DELETE("/:identity", func(ctx *gin.Context) { platform.ArchiveRecord(ctx, model) }) } + +func registerRestrictedWritableResource(group *gin.RouterGroup, path string, model any, fields []string) { + list, create, get, update := platform.ResourceHandlers(model, fields, fields) + registerWritableResource(group, path, list, create, get, update, model) +} diff --git a/backend/api/internal/routers/platform_test.go b/backend/api/internal/routers/platform_test.go index 331f70c..abd1dc9 100644 --- a/backend/api/internal/routers/platform_test.go +++ b/backend/api/internal/routers/platform_test.go @@ -56,6 +56,45 @@ func TestPlatformOrganizationAndAccountRoutesExposeResourceCRUD(t *testing.T) { assertRouteMethods(t, routes, "/heqi/platform/v1/platform/platform_role/:identity/menus", http.MethodPut) } +func TestPlatformDeviceSafetyCommerceAndDeliveryRoutesFollowTheirContracts(t *testing.T) { + engine := gin.New() + RegisterPlatform("heqi", engine) + + routes := make(map[string]map[string]bool) + for _, route := range engine.Routes() { + if routes[route.Path] == nil { + routes[route.Path] = make(map[string]bool) + } + routes[route.Path][route.Method] = true + } + + for _, resource := range []string{ + "/device/dev_smart_cylinder_valve", "/device/dev_device_binding", + "/safety/saf_rule", "/safety/saf_event", "/safety/saf_inspection", + "/ec/ec_category", "/ec/ec_product", "/ec/ec_product_attribute", "/ec/ec_product_image", "/ec/ec_cart", "/ec/ec_order", "/ec/ec_review", + "/delivery/delivery_task", "/delivery/delivery_track", "/delivery/delivery_track_point", + } { + assertRouteMethods(t, routes, "/heqi/platform/v1"+resource, http.MethodGet, http.MethodPost) + assertRouteMethods(t, routes, "/heqi/platform/v1"+resource+"/:identity", http.MethodGet, http.MethodPut, http.MethodDelete) + assertRouteMethods(t, routes, "/heqi/platform/v1"+resource+"/:identity/status", http.MethodPatch) + } + + telemetry := "/heqi/platform/v1/device/dev_telemetry" + assertRouteMethods(t, routes, telemetry, http.MethodGet) + assertRouteMethods(t, routes, telemetry+"/:identity", http.MethodGet) + for _, method := range []string{http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete} { + if routes[telemetry][method] || routes[telemetry+"/:identity"][method] { + t.Errorf("telemetry unexpectedly permits %s", method) + } + } + + disposal := "/heqi/platform/v1/safety/saf_event/:identity/disposals" + assertRouteMethods(t, routes, disposal, http.MethodPost) + if routes[disposal][http.MethodDelete] { + t.Fatal("safety event disposals must be append-only") + } +} + func assertRouteMethods(t *testing.T, routes map[string]map[string]bool, path string, methods ...string) { t.Helper() for _, method := range methods {