Skip to content

Commit 6a10da0

Browse files
sfe/zendesk: Add support for status to Zendesk client (#8367)
Part of #8166
1 parent d6e4f9a commit 6a10da0

File tree

4 files changed

+346
-70
lines changed

4 files changed

+346
-70
lines changed

sfe/zendesk/zendesk.go

Lines changed: 62 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"io"
88
"net/http"
99
"net/url"
10+
"slices"
1011
"strings"
1112
"time"
1213
)
@@ -17,6 +18,12 @@ const (
1718
searchJSONPath = apiPath + "search.json"
1819
)
1920

21+
// Note: This is client is NOT compatible with custom ticket statuses, it only
22+
// supports the default Zendesk ticket statuses. For more information, see:
23+
// https://developer.zendesk.com/api-reference/ticketing/tickets/custom_ticket_statuses
24+
// https://developer.zendesk.com/api-reference/ticketing/tickets/tickets/#custom-ticket-statuses
25+
var validStatuses = []string{"new", "open", "pending", "hold", "solved"}
26+
2027
// Client is a Zendesk client that allows you to create tickets, search for
2128
// tickets, and add comments to tickets via the Zendesk REST API. It uses basic
2229
// authentication with an API token.
@@ -27,7 +34,7 @@ type Client struct {
2734

2835
ticketsURL string
2936
searchURL string
30-
commentURL string
37+
updateURL string
3138

3239
nameToFieldID map[string]int64
3340
fieldIDToName map[int64]string
@@ -50,7 +57,7 @@ func NewClient(baseURL, tokenEmail, token string, nameToFieldID map[string]int64
5057
if err != nil {
5158
return nil, fmt.Errorf("failed to join search path: %w", err)
5259
}
53-
commentURL, err := url.JoinPath(baseURL, apiPath, "tickets")
60+
updateURL, err := url.JoinPath(baseURL, apiPath, "tickets")
5461
if err != nil {
5562
return nil, fmt.Errorf("failed to join comment path: %w", err)
5663
}
@@ -68,7 +75,7 @@ func NewClient(baseURL, tokenEmail, token string, nameToFieldID map[string]int64
6875
token: token,
6976
ticketsURL: ticketsURL,
7077
searchURL: searchURL,
71-
commentURL: commentURL,
78+
updateURL: updateURL,
7279
nameToFieldID: nameToFieldID,
7380
fieldIDToName: fieldIDToName,
7481
}, nil
@@ -245,13 +252,13 @@ func (c *Client) CreateTicket(requesterEmail, subject, commentBody string, field
245252
return result.Ticket.ID, nil
246253
}
247254

248-
// FindTickets returns all tickets whose custom fields match the supplied
249-
// matchFields. The matchFields map should contain the display names of the
250-
// custom fields as keys and the desired values as values. The method returns a
251-
// map where the keys are ticket IDs and the values are maps of custom field
252-
// names to their values. If no matchFields are supplied, an error is returned.
253-
// If a custom field name is unknown, an error is returned.
254-
func (c *Client) FindTickets(matchFields map[string]string) (map[int64]map[string]string, error) {
255+
// FindTickets returns all tickets whose custom fields match the required
256+
// matchFields and optional status. The matchFields map should contain the
257+
// display names of the custom fields as keys and the desired values as values.
258+
// The method returns a map where the keys are ticket IDs and the values are
259+
// maps of custom field names to their values. If no matchFields are supplied,
260+
// an error is returned. If a custom field name is unknown, an error is returned.
261+
func (c *Client) FindTickets(matchFields map[string]string, status string) (map[int64]map[string]string, error) {
255262
if len(matchFields) == 0 {
256263
return nil, fmt.Errorf("no match fields supplied")
257264
}
@@ -262,6 +269,13 @@ func (c *Client) FindTickets(matchFields map[string]string) (map[int64]map[strin
262269

263270
query := []string{"type:ticket"}
264271

272+
if status != "" {
273+
if !slices.Contains(validStatuses, status) {
274+
return nil, fmt.Errorf("invalid status %q, must be one of %s", status, validStatuses)
275+
}
276+
query = append(query, fmt.Sprintf("status:%s", status))
277+
}
278+
265279
for name, want := range matchFields {
266280
id, ok := c.nameToFieldID[name]
267281
if !ok {
@@ -325,7 +339,7 @@ func (c *Client) FindTickets(matchFields map[string]string) (map[int64]map[strin
325339
// added as a public or private comment based on the provided boolean value. An
326340
// error is returned if the request fails.
327341
func (c *Client) AddComment(ticketID int64, commentBody string, public bool) error {
328-
endpoint, err := url.JoinPath(c.commentURL, fmt.Sprintf("%d.json", ticketID))
342+
endpoint, err := url.JoinPath(c.updateURL, fmt.Sprintf("%d.json", ticketID))
329343
if err != nil {
330344
return fmt.Errorf("failed to join ticket path: %w", err)
331345
}
@@ -350,3 +364,40 @@ func (c *Client) AddComment(ticketID int64, commentBody string, public bool) err
350364
}
351365
return nil
352366
}
367+
368+
// UpdateTicketStatus updates the status of the specified ticket to the provided
369+
// status and adds a comment with the provided body. The comment is added as a
370+
// public or private comment based on the provided boolean value. An error is
371+
// returned if the request fails or if the provided status is invalid.
372+
func (c *Client) UpdateTicketStatus(ticketID int64, status string, commentBody string, public bool) error {
373+
if !slices.Contains(validStatuses, status) {
374+
return fmt.Errorf("invalid status %q, must be one of %s", status, validStatuses)
375+
}
376+
377+
endpoint, err := url.JoinPath(c.updateURL, fmt.Sprintf("%d.json", ticketID))
378+
if err != nil {
379+
return fmt.Errorf("failed to join ticket path: %w", err)
380+
}
381+
382+
// For more information on the status update format, see:
383+
// https://developer.zendesk.com/api-reference/ticketing/tickets/tickets/#update-ticket
384+
payload := struct {
385+
Ticket struct {
386+
Comment comment `json:"comment"`
387+
Status string `json:"status"`
388+
} `json:"ticket"`
389+
}{}
390+
payload.Ticket.Comment = comment{Body: commentBody, Public: public}
391+
payload.Ticket.Status = status
392+
393+
body, err := json.Marshal(payload)
394+
if err != nil {
395+
return fmt.Errorf("failed to marshal zendesk status update: %w", err)
396+
}
397+
398+
_, err = c.doJSONRequest(http.MethodPut, endpoint, body)
399+
if err != nil {
400+
return fmt.Errorf("failed to update zendesk ticket %d: %w", ticketID, err)
401+
}
402+
return nil
403+
}

sfe/zendesk/zendesk_test.go

Lines changed: 137 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"fmt"
55
"net/http"
66
"net/http/httptest"
7+
"net/url"
78
"strings"
89
"sync/atomic"
910
"testing"
@@ -188,6 +189,134 @@ func TestAddComment404(t *testing.T) {
188189
}
189190
}
190191

192+
func TestAddCommentEmptyBody422(t *testing.T) {
193+
t.Parallel()
194+
195+
c, _ := startMockClient(t)
196+
197+
id, err := c.CreateTicket("[email protected]", "s", "init", nil)
198+
if err != nil {
199+
t.Errorf("CreateTicket([email protected]): %s", err)
200+
}
201+
202+
err = c.AddComment(id, "", true)
203+
if err == nil || !strings.Contains(err.Error(), "status 422") {
204+
t.Errorf("expected HTTP 422 for empty comment body on ticket %d, got: %s", id, err)
205+
}
206+
}
207+
208+
func TestUpdateTicketStatus(t *testing.T) {
209+
t.Parallel()
210+
211+
type tc struct {
212+
name string
213+
status string
214+
comment *comment
215+
expectErr bool
216+
expectStatus string
217+
expectComment *comment
218+
}
219+
220+
cases := []tc{
221+
{
222+
name: "Update to open without comment",
223+
status: "open",
224+
expectErr: false,
225+
expectStatus: "open",
226+
},
227+
{
228+
name: "Update to pending with comment",
229+
status: "solved",
230+
comment: &comment{Body: "Resolved", Public: true},
231+
expectErr: false,
232+
expectStatus: "solved",
233+
expectComment: &comment{Body: "Resolved", Public: true},
234+
},
235+
{
236+
name: "Update from new to foo (invalid status)",
237+
status: "foo",
238+
expectErr: true,
239+
expectStatus: "new",
240+
},
241+
{
242+
name: "unknown id",
243+
status: "open",
244+
expectErr: true,
245+
expectStatus: "new",
246+
},
247+
}
248+
249+
for _, tc := range cases {
250+
t.Run(tc.name, func(t *testing.T) {
251+
t.Parallel()
252+
253+
fake := zendeskfake.NewServer(apiTokenEmail, apiToken, nil)
254+
ts := httptest.NewServer(fake.Handler())
255+
t.Cleanup(ts.Close)
256+
257+
client, err := NewClient(ts.URL, apiTokenEmail, apiToken, map[string]int64{})
258+
if err != nil {
259+
t.Errorf("Unexpected error from NewClient(%q): %s", ts.URL, err)
260+
}
261+
262+
client.updateURL, err = url.JoinPath(ts.URL, "/api/v2/tickets")
263+
if err != nil {
264+
t.Errorf("Failed to join update URL: %s", err)
265+
}
266+
267+
id, err := client.CreateTicket("[email protected]", "Some subject", "Some comment", nil)
268+
if err != nil {
269+
t.Errorf("Unexpected error from CreateTicket: %s", err)
270+
}
271+
272+
updateID := id
273+
if tc.name == "unknown id" {
274+
updateID = 999999
275+
}
276+
277+
var commentBody string
278+
var public bool
279+
if tc.comment != nil {
280+
commentBody = tc.comment.Body
281+
public = tc.comment.Public
282+
}
283+
err = client.UpdateTicketStatus(updateID, tc.status, commentBody, public)
284+
if tc.expectErr {
285+
if err == nil {
286+
t.Errorf("Expected error for status %q, got nil", tc.status)
287+
}
288+
} else {
289+
if err != nil {
290+
t.Errorf("Unexpected error for UpdateTicketStatus(%d, %q): %s", updateID, tc.status, err)
291+
}
292+
}
293+
294+
got, ok := fake.GetTicket(id)
295+
if !ok {
296+
t.Errorf("Ticket with id %d not found after update", id)
297+
}
298+
299+
if got.Status != tc.expectStatus {
300+
t.Errorf("Expected status %q, got %q", tc.expectStatus, got.Status)
301+
}
302+
if tc.expectComment != nil {
303+
found := false
304+
for _, c := range got.Comments {
305+
if c.Body == tc.expectComment.Body && c.Public == tc.expectComment.Public {
306+
found = true
307+
break
308+
}
309+
}
310+
if !found {
311+
t.Errorf("Expected comment not found: %#v in %#v", tc.expectComment, got.Comments)
312+
}
313+
} else if len(got.Comments) > 1 {
314+
t.Errorf("Expected no additional comment, got %d: %#v", len(got.Comments), got.Comments)
315+
}
316+
})
317+
}
318+
}
319+
191320
func TestFindTicketsSimple(t *testing.T) {
192321
t.Parallel()
193322

@@ -206,7 +335,7 @@ func TestFindTicketsSimple(t *testing.T) {
206335
t.Errorf("creating ticket 3: %s", err)
207336
}
208337

209-
got, err := c.FindTickets(map[string]string{"reviewStatus": "pending"})
338+
got, err := c.FindTickets(map[string]string{"reviewStatus": "pending"}, "new")
210339
if err != nil {
211340
t.Errorf("FindTickets(reviewStatus=pending): %s", err)
212341
}
@@ -234,7 +363,7 @@ func TestFindTicketsQuotedValueReturnsAll(t *testing.T) {
234363
}
235364
}
236365

237-
got, err := c.FindTickets(map[string]string{"reviewStatus": "needs review"})
366+
got, err := c.FindTickets(map[string]string{"reviewStatus": "needs review"}, "new")
238367
if err != nil {
239368
t.Errorf("FindTickets(needs review): %s", err)
240369
}
@@ -248,7 +377,7 @@ func TestFindTicketsNoMatchFieldsError(t *testing.T) {
248377

249378
c, _ := startMockClient(t)
250379

251-
_, err := c.FindTickets(map[string]string{})
380+
_, err := c.FindTickets(map[string]string{}, "new")
252381
if err == nil || !strings.Contains(err.Error(), "no match fields") {
253382
t.Errorf("expected error for empty match fields, got: %s", err)
254383
}
@@ -259,28 +388,12 @@ func TestFindTicketsUnknownFieldName(t *testing.T) {
259388

260389
c, _ := startMockClient(t)
261390

262-
_, err := c.FindTickets(map[string]string{"unknown": "v"})
391+
_, err := c.FindTickets(map[string]string{"unknown": "v"}, "new")
263392
if err == nil || !strings.Contains(err.Error(), "unknown custom field") {
264393
t.Errorf("expected unknown custom field error, got: %s", err)
265394
}
266395
}
267396

268-
func TestAddCommentEmptyBody422(t *testing.T) {
269-
t.Parallel()
270-
271-
c, _ := startMockClient(t)
272-
273-
id, err := c.CreateTicket("[email protected]", "s", "init", nil)
274-
if err != nil {
275-
t.Errorf("CreateTicket([email protected]): %s", err)
276-
}
277-
278-
err = c.AddComment(id, "", true)
279-
if err == nil || !strings.Contains(err.Error(), "status 422") {
280-
t.Errorf("expected HTTP 422 for empty comment body on ticket %d, got: %s", id, err)
281-
}
282-
}
283-
284397
func TestFindTicketsNoResults(t *testing.T) {
285398
t.Parallel()
286399

@@ -290,7 +403,7 @@ func TestFindTicketsNoResults(t *testing.T) {
290403
if err != nil {
291404
t.Errorf("creating ticket with reviewStatus=approved: %s", err)
292405
}
293-
got, err := c.FindTickets(map[string]string{"reviewStatus": "pending"})
406+
got, err := c.FindTickets(map[string]string{"reviewStatus": "pending"}, "new")
294407
if err != nil {
295408
t.Errorf("FindTickets(reviewStatus=pending): %s", err)
296409
}
@@ -335,7 +448,7 @@ func TestFindTicketsPaginationFollowed(t *testing.T) {
335448
}
336449
}
337450

338-
got, err := c.FindTickets(map[string]string{"reviewStatus": "needs review"})
451+
got, err := c.FindTickets(map[string]string{"reviewStatus": "needs review"}, "")
339452
if err != nil {
340453
t.Errorf("FindTickets(needs review): %s", err)
341454
}
@@ -362,7 +475,7 @@ func TestFindTicketsHTTP400(t *testing.T) {
362475
if err != nil {
363476
t.Errorf("NewClient(%q): %s", ts.URL, err)
364477
}
365-
_, err = c.FindTickets(map[string]string{"reviewStatus": "needs review"})
478+
_, err = c.FindTickets(map[string]string{"reviewStatus": "needs review"}, "new")
366479
if err == nil || !strings.Contains(err.Error(), "status 400") {
367480
t.Errorf("expected HTTP 400 from search, got: %s", err)
368481
}
@@ -382,7 +495,7 @@ func TestFindTicketsHTTP500(t *testing.T) {
382495
if err != nil {
383496
t.Errorf("NewClient(%q): %s", ts.URL, err)
384497
}
385-
_, err = c.FindTickets(map[string]string{"reviewStatus": "needs review"})
498+
_, err = c.FindTickets(map[string]string{"reviewStatus": "needs review"}, "new")
386499
if err == nil || !strings.Contains(err.Error(), "status 500") {
387500
t.Errorf("expected HTTP 500 from search, got: %s", err)
388501
}

0 commit comments

Comments
 (0)