From 510a670ca6d43fbe34913fe6071880b36207d528 Mon Sep 17 00:00:00 2001 From: Nick Mills-Barrett Date: Wed, 28 Oct 2026 16:27:24 +0000 Subject: [PATCH] polls: support creation and preserve validation errors --- client.go | 2 +- json.go | 28 +++++++++++++++++++++++++++ poll_api.go | 34 +++++++++++++++++++++++++++++++- poll_api_test.go | 50 ++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 112 insertions(+), 2 deletions(-) create mode 100644 poll_api_test.go diff --git a/client.go b/client.go index 405448a..a704ae5 100644 --- a/client.go +++ b/client.go @@ -48,7 +48,7 @@ func (c Client) String() string { /*/// Handle parsing of nested interface type response ///*/ type jsonResponse struct { Response response `json:"response"` - Meta `json:"meta"` + Meta Meta `json:"meta"` } func newJSONResponse(i interface{}) *jsonResponse { diff --git a/json.go b/json.go index a65e2a4..9ae5140 100755 --- a/json.go +++ b/json.go @@ -4,6 +4,7 @@ package groupme import ( "encoding/json" "fmt" + "sort" ) // Meta is the error type returned in the GroupMe response. @@ -13,6 +14,33 @@ type Meta struct { Errors []string `json:"errors,omitempty"` } +func (m *Meta) UnmarshalJSON(data []byte) error { + var raw struct { + Code HTTPStatusCode `json:"code"` + Errors json.RawMessage `json:"errors"` + } + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + m.Code, m.Errors = raw.Code, nil + if len(raw.Errors) == 0 { + return nil + } + if err := json.Unmarshal(raw.Errors, &m.Errors); err == nil { + return nil + } + // Poll validation errors are keyed by the rejected field. + var fields map[string]string + if err := json.Unmarshal(raw.Errors, &fields); err != nil { + return err + } + for field, reason := range fields { + m.Errors = append(m.Errors, field+": "+reason) + } + sort.Strings(m.Errors) + return nil +} + // Error returns the code and the error list as a string. // Satisfies the error interface func (m Meta) Error() string { diff --git a/poll_api.go b/poll_api.go index 5ae4cab..eef4987 100644 --- a/poll_api.go +++ b/poll_api.go @@ -34,7 +34,7 @@ type PollDetail struct { LastModified Timestamp `json:"last_modified,omitempty"` // Type is "single" or "multi". Type string `json:"type,omitempty"` - // Visibility has only been observed as "anonymous". + // Visibility is "public" or "anonymous". Visibility string `json:"visibility,omitempty"` // MyVotes is the authenticated user's selection from the poll envelope. MyVotes []string `json:"-"` @@ -48,6 +48,38 @@ type getPollResponse struct { } `json:"poll"` } +type CreatePollRequest struct { + Subject string `json:"subject"` + Options []PollOption `json:"options"` + Expiration Timestamp `json:"expiration"` + Visibility string `json:"visibility"` + Type string `json:"type"` +} + +// CreatePoll returns the poll and the ID of its poll.created announcement. +func (c *Client) CreatePoll(ctx context.Context, conversationID string, poll *CreatePollRequest) (*PollDetail, ID, error) { + body, err := json.Marshal(poll) + if err != nil { + return nil, "", err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.endpointBase+"/poll/"+url.PathEscape(conversationID), bytes.NewReader(body)) + if err != nil { + return nil, "", err + } + var resp struct { + getPollResponse + Message Message `json:"message"` + } + if err := c.doWithAuthToken(ctx, req, &resp); err != nil { + return nil, "", err + } + if resp.Poll.Data.ID == "" || resp.Message.ID == "" { + return nil, "", fmt.Errorf("GroupMe poll creation response has a missing poll or message ID") + } + result, err := resp.result(conversationID, resp.Poll.Data.ID) + return result, resp.Message.ID, err +} + func (c *Client) GetPoll(ctx context.Context, conversationID, pollID string) (*PollDetail, error) { url := fmt.Sprintf(c.endpointBase+pollEndpoint, url.PathEscape(conversationID), url.PathEscape(pollID)) diff --git a/poll_api_test.go b/poll_api_test.go new file mode 100644 index 0000000..e13998b --- /dev/null +++ b/poll_api_test.go @@ -0,0 +1,50 @@ +package groupme + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "reflect" + "testing" +) + +func TestCreatePoll(t *testing.T) { + for _, tc := range []struct { + name, response string + status int + reason string + }{ + {"created", `{"meta":{"code":20100},"response":{"poll":{"data":{"id":"poll-1","conversation_id":"88","options":[{"id":"7","title":"Yes"},{"id":"9","title":"No"}]}},"message":{"id":"announcement-1"}}}`, 201, ""}, + {"invalid expiration", `{"meta":{"code":40002,"errors":["Poll expiration is invalid"]}}`, 400, "Poll expiration is invalid"}, + {"invalid options", `{"meta":{"code":40003,"errors":{"options":"the length must be between 2 and 30"}}}`, 400, "options: the length must be between 2 and 30"}, + } { + t.Run(tc.name, func(t *testing.T) { + input := &CreatePollRequest{Subject: "Choose", Options: []PollOption{{Title: "Yes"}, {Title: "No"}}, Expiration: 1794414187, Visibility: "public", Type: "single"} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" || r.URL.Path != "/poll/88" || r.Header.Get("X-Access-Token") != "test-token" || r.Header.Get("Content-Type") != "application/json" { + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + } + var got CreatePollRequest + if err := json.NewDecoder(r.Body).Decode(&got); err != nil || !reflect.DeepEqual(&got, input) { + t.Errorf("unexpected request body: %+v, %v", got, err) + } + w.WriteHeader(tc.status) + _, _ = w.Write([]byte(tc.response)) + })) + defer server.Close() + client := NewClient("test-token") + client.endpointBase = server.URL + poll, messageID, err := client.CreatePoll(context.Background(), "88", input) + if tc.reason != "" { + var meta *Meta + if !errors.As(err, &meta) || meta.Code != 400 || !reflect.DeepEqual(meta.Errors, []string{tc.reason}) { + t.Fatalf("unexpected rejection: %v", err) + } + } else if err != nil || messageID != "announcement-1" || poll.ID != "poll-1" || poll.Options[0].ID != "7" || poll.Options[1].ID != "9" { + t.Fatalf("unexpected create result: %+v, %s, %v", poll, messageID, err) + } + }) + } +}