From ffbe5cb9b5ac8145dbd012951b15f905aabe85ec Mon Sep 17 00:00:00 2001 From: Cheng-Lung Sung Date: Tue, 25 Feb 2020 11:26:15 +0800 Subject: [PATCH] Support narrowcast message API (#191) * upport narrowcast message API - implments #190 TODO: - narrowcast progress API * fix filter.demographic * Update comment * rename fliter to selector * remove comment * add `RequestID` on BasicResponse * seperate interfaces by design * Operators for Recipient and Demographic Filter, respectively * add example test --- linebot/client.go | 1 + linebot/demographic_filter.go | 300 ++++++++++++++++++++++++++++++++++ linebot/recipient.go | 85 ++++++++++ linebot/response.go | 5 +- linebot/send_message.go | 82 ++++++++++ linebot/send_message_test.go | 162 ++++++++++++++++++ 6 files changed, 634 insertions(+), 1 deletion(-) create mode 100644 linebot/demographic_filter.go create mode 100644 linebot/recipient.go diff --git a/linebot/client.go b/linebot/client.go index 6cf2f487..bb4df1e0 100644 --- a/linebot/client.go +++ b/linebot/client.go @@ -33,6 +33,7 @@ const ( APIEndpointBroadcastMessage = "/v2/bot/message/broadcast" APIEndpointReplyMessage = "/v2/bot/message/reply" APIEndpointMulticast = "/v2/bot/message/multicast" + APIEndpointNarrowcast = "/v2/bot/message/narrowcast" APIEndpointGetMessageContent = "/v2/bot/message/%s/content" APIEndpointGetMessageQuota = "/v2/bot/message/quota" APIEndpointGetMessageConsumption = "/v2/bot/message/quota/consumption" diff --git a/linebot/demographic_filter.go b/linebot/demographic_filter.go new file mode 100644 index 00000000..eddd9e72 --- /dev/null +++ b/linebot/demographic_filter.go @@ -0,0 +1,300 @@ +// Copyright 2020 LINE Corporation +// +// LINE Corporation licenses this file to you under the Apache License, +// version 2.0 (the "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at: +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +// License for the specific language governing permissions and limitations +// under the License. + +package linebot + +import "encoding/json" + +// DemographicFilter interface +type DemographicFilter interface { + DemographicFilter() +} + +// GenderType type +type GenderType string + +// GenderType constants +const ( + GenderMale GenderType = "male" + GenderFemale GenderType = "female" +) + +// GenderFilter type +type GenderFilter struct { + Type string `json:"type"` + Genders []GenderType `json:"oneOf"` +} + +// NewGenderFilter function +func NewGenderFilter(genders ...GenderType) *GenderFilter { + return &GenderFilter{ + Type: "gender", + Genders: genders, + } +} + +// DemographicFilter implements DemographicFilter interface +func (*GenderFilter) DemographicFilter() {} + +// AgeType type +type AgeType string + +// AgeType constants +const ( + AgeEmpty AgeType = "" + Age15 AgeType = "age_15" + Age20 AgeType = "age_20" + Age25 AgeType = "age_25" + Age30 AgeType = "age_30" + Age35 AgeType = "age_35" + Age40 AgeType = "age_40" + Age45 AgeType = "age_45" + Age50 AgeType = "age_50" +) + +// AgeFilter type +type AgeFilter struct { + Type string `json:"type"` + GTE AgeType `json:"gte,omitempty"` // greater than or equal to + LT AgeType `json:"lt,omitempty"` // less than +} + +// NewAgeFilter function +func NewAgeFilter(gte, lt AgeType) *AgeFilter { + return &AgeFilter{ + Type: "age", + GTE: gte, + LT: lt, + } +} + +// DemographicFilter implements DemographicFilter interface +func (*AgeFilter) DemographicFilter() {} + +// AppType type +type AppType string + +// AppType constants +const ( + AppTypeIOS AppType = "ios" + AppTypeAndroid AppType = "android" +) + +// AppTypeFilter type +type AppTypeFilter struct { + Type string `json:"type"` + AppTypes []AppType `json:"oneOf"` +} + +// NewAppTypeFilter function +func NewAppTypeFilter(appTypes ...AppType) *AppTypeFilter { + return &AppTypeFilter{ + Type: "appType", + AppTypes: appTypes, + } +} + +// DemographicFilter implements DemographicFilter interface +func (*AppTypeFilter) DemographicFilter() {} + +// AreaType type +type AreaType string + +// AreaType constants +const ( + AreaJPHokkaido AreaType = "jp_01" + AreaJPAomori AreaType = "jp_02" + AreaJPIwate AreaType = "jp_03" + AreaJPMiyagi AreaType = "jp_04" + AreaJPAkita AreaType = "jp_05" + AreaJPYamagata AreaType = "jp_06" + AreaJPFukushima AreaType = "jp_07" + AreaJPIbaraki AreaType = "jp_08" + AreaJPTochigi AreaType = "jp_09" + AreaJPGunma AreaType = "jp_10" + AreaJPSaitama AreaType = "jp_11" + AreaJPChiba AreaType = "jp_12" + AreaJPTokyo AreaType = "jp_13" + AreaJPKanagawa AreaType = "jp_14" + AreaJPNiigata AreaType = "jp_15" + AreaJPToyama AreaType = "jp_16" + AreaJPIshikawa AreaType = "jp_17" + AreaJPFukui AreaType = "jp_18" + AreaJPYamanashi AreaType = "jp_19" + AreaJPNagano AreaType = "jp_20" + AreaJPGifu AreaType = "jp_21" + AreaJPShizuoka AreaType = "jp_22" + AreaJPAichi AreaType = "jp_23" + AreaJPMie AreaType = "jp_24" + AreaJPShiga AreaType = "jp_25" + AreaJPKyoto AreaType = "jp_26" + AreaJPOsaka AreaType = "jp_27" + AreaJPHyougo AreaType = "jp_28" + AreaJPNara AreaType = "jp_29" + AreaJPWakayama AreaType = "jp_30" + AreaJPTottori AreaType = "jp_31" + AreaJPShimane AreaType = "jp_32" + AreaJPOkayama AreaType = "jp_33" + AreaJPHiroshima AreaType = "jp_34" + AreaJPYamaguchi AreaType = "jp_35" + AreaJPTokushima AreaType = "jp_36" + AreaJPKagawa AreaType = "jp_37" + AreaJPEhime AreaType = "jp_38" + AreaJPKouchi AreaType = "jp_39" + AreaJPFukuoka AreaType = "jp_40" + AreaJPSaga AreaType = "jp_41" + AreaJPNagasaki AreaType = "jp_42" + AreaJPKumamoto AreaType = "jp_43" + AreaJPOita AreaType = "jp_44" + AreaJPMiyazaki AreaType = "jp_45" + AreaJPKagoshima AreaType = "jp_46" + AreaJPOkinawa AreaType = "jp_47" + AreaTWTaipeiCity AreaType = "tw_01" + AreaTWNewTaipeiCity AreaType = "tw_02" + AreaTWTaoyuanCity AreaType = "tw_03" + AreaTWTaichungCity AreaType = "tw_04" + AreaTWTainanCity AreaType = "tw_05" + AreaTWKaohsiungCity AreaType = "tw_06" + AreaTWKeelungCity AreaType = "tw_07" + AreaTWHsinchuCity AreaType = "tw_08" + AreaTWChiayiCity AreaType = "tw_09" + AreaTWHsinchuCounty AreaType = "tw_10" + AreaTWMiaoliCounty AreaType = "tw_11" + AreaTWChanghuaCounty AreaType = "tw_12" + AreaTWNantouCounty AreaType = "tw_13" + AreaTWYunlinCounty AreaType = "tw_14" + AreaTWChiayiCounty AreaType = "tw_15" + AreaTWPingtungCounty AreaType = "tw_16" + AreaTWYilanCounty AreaType = "tw_17" + AreaTWHualienCounty AreaType = "tw_18" + AreaTWTaitungCounty AreaType = "tw_19" + AreaTWPenghuCounty AreaType = "tw_20" + AreaTWKinmenCounty AreaType = "tw_21" + AreaTWLienchiangCounty AreaType = "tw_22" + AreaTHBangkok AreaType = "th_01" + AreaTHPattaya AreaType = "th_02" + AreaTHNorthern AreaType = "th_03" + AreaTHCentral AreaType = "th_04" + AreaTHSouthern AreaType = "th_05" + AreaTHEastern AreaType = "th_06" + AreaTHNorthEastern AreaType = "th_07" + AreaTHWestern AreaType = "th_08" + AreaIDBali AreaType = "id_01" + AreaIDBandung AreaType = "id_02" + AreaIDBanjarmasin AreaType = "id_03" + AreaIDJabodetabek AreaType = "id_04" + AreaIDLainnya AreaType = "id_05" + AreaIDMakassar AreaType = "id_06" + AreaIDMedan AreaType = "id_07" + AreaIDPalembang AreaType = "id_08" + AreaIDSamarinda AreaType = "id_09" + AreaIDSemarang AreaType = "id_10" + AreaIDSurabaya AreaType = "id_11" + AreaIDYogyakarta AreaType = "id_12" +) + +// AreaFilter type +type AreaFilter struct { + Type string `json:"type"` + Areas []AreaType `json:"oneOf"` +} + +// NewAreaFilter function +func NewAreaFilter(areaTypes ...AreaType) *AreaFilter { + return &AreaFilter{ + Type: "area", + Areas: areaTypes, + } +} + +// DemographicFilter implements DemographicFilter interface +func (*AreaFilter) DemographicFilter() {} + +// PeriodType type +type PeriodType string + +// PeriodType constants +const ( + PeriodEmpty PeriodType = "" + PeriodDay7 PeriodType = "day_7" + PeriodDay30 PeriodType = "day_30" + PeriodDay90 PeriodType = "day_90" + PeriodDay180 PeriodType = "day_180" + PeriodDay365 PeriodType = "day_365" +) + +// SubscriptionPeriodFilter type +type SubscriptionPeriodFilter struct { + Type string `json:"type"` + GTE PeriodType `json:"gte,omitempty"` // greater than or equal to + LT PeriodType `json:"lt,omitempty"` // less than +} + +// NewSubscriptionPeriodFilter function +func NewSubscriptionPeriodFilter(gte, lt PeriodType) *SubscriptionPeriodFilter { + return &SubscriptionPeriodFilter{ + Type: "subscriptionPeriod", + GTE: gte, + LT: lt, + } +} + +// DemographicFilter implements DemographicFilter interface +func (*SubscriptionPeriodFilter) DemographicFilter() {} + +// DemographicFilterOperator struct +type DemographicFilterOperator struct { + ConditionAnd []DemographicFilter `json:"and,omitempty"` + ConditionOr []DemographicFilter `json:"or,omitempty"` + ConditionNot DemographicFilter `json:"not,omitempty"` +} + +// DemographicFilterOperatorAnd method +func DemographicFilterOperatorAnd(conditions ...DemographicFilter) *DemographicFilterOperator { + return &DemographicFilterOperator{ + ConditionAnd: conditions, + } +} + +// DemographicFilterOperatorOr method +func DemographicFilterOperatorOr(conditions ...DemographicFilter) *DemographicFilterOperator { + return &DemographicFilterOperator{ + ConditionOr: conditions, + } +} + +// DemographicFilterOperatorNot method +func DemographicFilterOperatorNot(condition DemographicFilter) *DemographicFilterOperator { + return &DemographicFilterOperator{ + ConditionNot: condition, + } +} + +// MarshalJSON method of DemographicFilterOperator +func (o *DemographicFilterOperator) MarshalJSON() ([]byte, error) { + return json.Marshal(&struct { + Type string `json:"type"` + ConditionAnd []DemographicFilter `json:"and,omitempty"` + ConditionOr []DemographicFilter `json:"or,omitempty"` + ConditionNot DemographicFilter `json:"not,omitempty"` + }{ + Type: "operator", + ConditionAnd: o.ConditionAnd, + ConditionOr: o.ConditionOr, + ConditionNot: o.ConditionNot, + }) +} + +// DemographicFilter implements DemographicFilter interface +func (*DemographicFilterOperator) DemographicFilter() {} diff --git a/linebot/recipient.go b/linebot/recipient.go new file mode 100644 index 00000000..ec37b338 --- /dev/null +++ b/linebot/recipient.go @@ -0,0 +1,85 @@ +// Copyright 2020 LINE Corporation +// +// LINE Corporation licenses this file to you under the Apache License, +// version 2.0 (the "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at: +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +// License for the specific language governing permissions and limitations +// under the License. + +package linebot + +import "encoding/json" + +// Recipient interface +type Recipient interface { + Recipient() +} + +// AudienceObject type is created to be used with specific recipient objects +type AudienceObject struct { + Type string `json:"type"` + GroupID int `json:"audienceGroupId"` +} + +// NewAudienceObject function +func NewAudienceObject(groupID int) *AudienceObject { + return &AudienceObject{ + Type: "audience", + GroupID: groupID, + } +} + +// Recipient implements Recipient interface +func (*AudienceObject) Recipient() {} + +// RecipientOperator struct +type RecipientOperator struct { + ConditionAnd []Recipient `json:"and,omitempty"` + ConditionOr []Recipient `json:"or,omitempty"` + ConditionNot Recipient `json:"not,omitempty"` +} + +// RecipientOperatorAnd method +func RecipientOperatorAnd(conditions ...Recipient) *RecipientOperator { + return &RecipientOperator{ + ConditionAnd: conditions, + } +} + +// RecipientOperatorOr method +func RecipientOperatorOr(conditions ...Recipient) *RecipientOperator { + return &RecipientOperator{ + ConditionOr: conditions, + } +} + +// RecipientOperatorNot method +func RecipientOperatorNot(condition Recipient) *RecipientOperator { + return &RecipientOperator{ + ConditionNot: condition, + } +} + +// MarshalJSON method of Operator +func (o *RecipientOperator) MarshalJSON() ([]byte, error) { + return json.Marshal(&struct { + Type string `json:"type"` + ConditionAnd []Recipient `json:"and,omitempty"` + ConditionOr []Recipient `json:"or,omitempty"` + ConditionNot Recipient `json:"not,omitempty"` + }{ + Type: "operator", + ConditionAnd: o.ConditionAnd, + ConditionOr: o.ConditionOr, + ConditionNot: o.ConditionNot, + }) +} + +// Recipient implements Recipient interface +func (*RecipientOperator) Recipient() {} diff --git a/linebot/response.go b/linebot/response.go index 03ae9153..9e2550dc 100644 --- a/linebot/response.go +++ b/linebot/response.go @@ -24,6 +24,7 @@ import ( // BasicResponse type type BasicResponse struct { + RequestID string } type errorResponseDetail struct { @@ -247,7 +248,9 @@ func decodeToBasicResponse(res *http.Response) (*BasicResponse, error) { return nil, err } decoder := json.NewDecoder(res.Body) - result := BasicResponse{} + result := BasicResponse{ + RequestID: res.Header.Get("X-Line-Request-Id"), + } if err := decoder.Decode(&result); err != nil { if err == io.EOF { return &result, nil diff --git a/linebot/send_message.go b/linebot/send_message.go index 7753b5d0..13332767 100644 --- a/linebot/send_message.go +++ b/linebot/send_message.go @@ -242,3 +242,85 @@ func (call *BroadcastMessageCall) Do() (*BasicResponse, error) { defer closeResponse(res) return decodeToBasicResponse(res) } + +// Narrowcast method +func (client *Client) Narrowcast(messages ...SendingMessage) *NarrowcastCall { + return &NarrowcastCall{ + c: client, + messages: messages, + } +} + +// NarrowcastCall type +type NarrowcastCall struct { + c *Client + ctx context.Context + + messages []SendingMessage + recipient Recipient + filter *Filter + limit *NarrowcastMessageLimit +} + +// Filter type +type Filter struct { + Demographic DemographicFilter `json:"demographic"` +} + +// NarrowcastMessageLimit type +type NarrowcastMessageLimit struct { + Max int `json:"max"` +} + +// WithContext method +func (call *NarrowcastCall) WithContext(ctx context.Context) *NarrowcastCall { + call.ctx = ctx + return call +} + +// WithRecipient method will send to specific recipient objects +func (call *NarrowcastCall) WithRecipient(recipient Recipient) *NarrowcastCall { + call.recipient = recipient + return call +} + +// WithDemographic method will send to specific recipients filter by demographic +func (call *NarrowcastCall) WithDemographic(demographic DemographicFilter) *NarrowcastCall { + call.filter = &Filter{Demographic: demographic} + return call +} + +// WithLimitMax method will set maximum number of recipients +func (call *NarrowcastCall) WithLimitMax(max int) *NarrowcastCall { + call.limit = &NarrowcastMessageLimit{Max: max} + return call +} + +func (call *NarrowcastCall) encodeJSON(w io.Writer) error { + enc := json.NewEncoder(w) + return enc.Encode(&struct { + Messages []SendingMessage `json:"messages"` + Recipient Recipient `json:"recipient,omitempty"` + Filter *Filter `json:"filter,omitempty"` + Limit *NarrowcastMessageLimit `json:"limit,omitempty"` + }{ + Messages: call.messages, + Recipient: call.recipient, + Filter: call.filter, + Limit: call.limit, + }) +} + +// Do method +func (call *NarrowcastCall) Do() (*BasicResponse, error) { + var buf bytes.Buffer + if err := call.encodeJSON(&buf); err != nil { + return nil, err + } + res, err := call.c.post(call.ctx, APIEndpointNarrowcast, &buf) + if err != nil { + return nil, err + } + defer closeResponse(res) + return decodeToBasicResponse(res) +} diff --git a/linebot/send_message_test.go b/linebot/send_message_test.go index 48af0877..2a50b18d 100644 --- a/linebot/send_message_test.go +++ b/linebot/send_message_test.go @@ -1415,6 +1415,7 @@ func TestBroadcastMessagesWithContext(t *testing.T) { _, err = client.BroadcastMessage(NewTextMessage("Hello, world")).WithContext(ctx).Do() expectCtxDeadlineExceed(ctx, err, t) } + func TestMessagesWithNotificationDisabled(t *testing.T) { type testMethod interface { Do() (*BasicResponse, error) @@ -1532,6 +1533,167 @@ func TestMessagesWithNotificationDisabled(t *testing.T) { } } +func TestNarrowcastMessages(t *testing.T) { + type want struct { + RequestBody []byte + Response *BasicResponse + Error error + } + var testCases = []struct { + Label string + Messages []SendingMessage + Recipient Recipient + Demographic DemographicFilter + Max int + RequestID string + Response []byte + ResponseCode int + Want want + }{ + { + Label: "A text message for Narrowcast Message with Audience", + Messages: []SendingMessage{NewTextMessage("Hello, world")}, + Recipient: RecipientOperatorAnd( + NewAudienceObject(5614991017776), + RecipientOperatorNot( + NewAudienceObject(4389303728991), + ), + ), + Demographic: nil, + Max: 0, + RequestID: "12222", + Response: []byte(`{}`), + ResponseCode: 202, + Want: want{ + RequestBody: []byte(`{"messages":[{"type":"text","text":"Hello, world"}],"recipient":{"type":"operator","and":[{"type":"audience","audienceGroupId":5614991017776},{"type":"operator","not":{"type":"audience","audienceGroupId":4389303728991}}]}}` + "\n"), + Response: &BasicResponse{RequestID: "12222"}, + }, + }, + { + Label: "A text message for Narrowcast Message for android", + Messages: []SendingMessage{NewTextMessage("Hello, world")}, + Recipient: nil, + Demographic: NewAppTypeFilter(AppTypeAndroid), + Max: 0, + RequestID: "22222", + Response: []byte(`{}`), + ResponseCode: 202, + Want: want{ + RequestBody: []byte(`{"messages":[{"type":"text","text":"Hello, world"}],"filter":{"demographic":{"type":"appType","oneOf":["android"]}}}` + "\n"), + Response: &BasicResponse{RequestID: "22222"}, + }, + }, + { + Label: "A text message for Narrowcast Message for male and age >= 30 and limit max to 10", + Messages: []SendingMessage{NewTextMessage("Hello, world")}, + Recipient: nil, + Demographic: DemographicFilterOperatorAnd(NewGenderFilter(GenderMale), NewAgeFilter(Age30, AgeEmpty)), + Max: 10, + RequestID: "32222", + Response: []byte(`{}`), + ResponseCode: 202, + Want: want{ + RequestBody: []byte(`{"messages":[{"type":"text","text":"Hello, world"}],"filter":{"demographic":{"type":"operator","and":[{"type":"gender","oneOf":["male"]},{"type":"age","gte":"age_30"}]}},"limit":{"max":10}}` + "\n"), + Response: &BasicResponse{RequestID: "32222"}, + }, + }, + { + Label: "An example message for sending narrowcast message based on official documentation", + Messages: []SendingMessage{NewTextMessage("test message")}, + Recipient: RecipientOperatorAnd( + NewAudienceObject(5614991017776), + RecipientOperatorNot( + NewAudienceObject(4389303728991), + ), + ), + Demographic: DemographicFilterOperatorOr( + DemographicFilterOperatorAnd( + NewGenderFilter(GenderMale, GenderFemale), + NewAgeFilter(Age20, Age25), + NewAppTypeFilter(AppTypeAndroid, AppTypeIOS), + NewAreaFilter(AreaJPAichi, AreaJPAkita), + NewSubscriptionPeriodFilter(PeriodDay7, PeriodDay30), + ), + DemographicFilterOperatorAnd( + NewAgeFilter(Age35, Age40), + DemographicFilterOperatorNot(NewGenderFilter(GenderMale)), + ), + ), + Max: 100, + RequestID: "32222", + Response: []byte(`{}`), + ResponseCode: 202, + Want: want{ + RequestBody: []byte(`{"messages":[{"type":"text","text":"test message"}],"recipient":{"type":"operator","and":[{"type":"audience","audienceGroupId":5614991017776},{"type":"operator","not":{"type":"audience","audienceGroupId":4389303728991}}]},"filter":{"demographic":{"type":"operator","or":[{"type":"operator","and":[{"type":"gender","oneOf":["male","female"]},{"type":"age","gte":"age_20","lt":"age_25"},{"type":"appType","oneOf":["android","ios"]},{"type":"area","oneOf":["jp_23","jp_05"]},{"type":"subscriptionPeriod","gte":"day_7","lt":"day_30"}]},{"type":"operator","and":[{"type":"age","gte":"age_35","lt":"age_40"},{"type":"operator","not":{"type":"gender","oneOf":["male"]}}]}]}},"limit":{"max":100}}` + "\n"), + Response: &BasicResponse{RequestID: "32222"}, + }, + }, + } + + var currentTestIdx int + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + if r.Method != http.MethodPost { + t.Errorf("Method %s; want %s", r.Method, http.MethodPost) + } + body, err := ioutil.ReadAll(r.Body) + if err != nil { + t.Fatal(err) + } + tc := testCases[currentTestIdx] + if !reflect.DeepEqual(body, tc.Want.RequestBody) { + t.Errorf("RequestBody \n%s; want \n%s", body, tc.Want.RequestBody) + } + w.Header().Set("X-Line-Request-Id", tc.RequestID) + w.WriteHeader(tc.ResponseCode) + w.Write(tc.Response) + })) + defer server.Close() + + dataServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + t.Error("Unexpected data API call") + w.WriteHeader(404) + w.Write([]byte(`{"message":"Not found"}`)) + })) + defer dataServer.Close() + + client, err := mockClient(server, dataServer) + if err != nil { + t.Fatal(err) + } + for i, tc := range testCases { + currentTestIdx = i + t.Run(strconv.Itoa(i)+"/"+tc.Label, func(t *testing.T) { + narrowCast := client.Narrowcast(tc.Messages...) + if tc.Recipient != nil { + narrowCast = narrowCast.WithRecipient(tc.Recipient) + } + if tc.Demographic != nil { + narrowCast = narrowCast.WithDemographic(tc.Demographic) + } + if tc.Max > 0 { + narrowCast = narrowCast.WithLimitMax(tc.Max) + } + res, err := narrowCast.Do() + if tc.Want.Error != nil { + if !reflect.DeepEqual(err, tc.Want.Error) { + t.Errorf("Error %d %v; want %v", i, err, tc.Want.Error) + } + } else { + if err != nil { + t.Error(err) + } + } + if tc.Want.Response != nil { + if !reflect.DeepEqual(res, tc.Want.Response) { + t.Errorf("Response %d %v; want %v", i, res, tc.Want.Response) + } + } + }) + } +} + func BenchmarkPushMessages(b *testing.B) { server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { defer r.Body.Close()