Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 62 additions & 11 deletions sfe/zendesk/zendesk.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"io"
"net/http"
"net/url"
"slices"
"strings"
"time"
)
Expand All @@ -17,6 +18,12 @@ const (
searchJSONPath = apiPath + "search.json"
)

// Note: This is client is NOT compatible with custom ticket statuses, it only
// supports the default Zendesk ticket statuses. For more information, see:
// https://developer.zendesk.com/api-reference/ticketing/tickets/custom_ticket_statuses
// https://developer.zendesk.com/api-reference/ticketing/tickets/tickets/#custom-ticket-statuses
var validStatuses = []string{"new", "open", "pending", "hold", "solved"}

// Client is a Zendesk client that allows you to create tickets, search for
// tickets, and add comments to tickets via the Zendesk REST API. It uses basic
// authentication with an API token.
Expand All @@ -27,7 +34,7 @@ type Client struct {

ticketsURL string
searchURL string
commentURL string
updateURL string

nameToFieldID map[string]int64
fieldIDToName map[int64]string
Expand All @@ -50,7 +57,7 @@ func NewClient(baseURL, tokenEmail, token string, nameToFieldID map[string]int64
if err != nil {
return nil, fmt.Errorf("failed to join search path: %w", err)
}
commentURL, err := url.JoinPath(baseURL, apiPath, "tickets")
updateURL, err := url.JoinPath(baseURL, apiPath, "tickets")
if err != nil {
return nil, fmt.Errorf("failed to join comment path: %w", err)
}
Expand All @@ -68,7 +75,7 @@ func NewClient(baseURL, tokenEmail, token string, nameToFieldID map[string]int64
token: token,
ticketsURL: ticketsURL,
searchURL: searchURL,
commentURL: commentURL,
updateURL: updateURL,
nameToFieldID: nameToFieldID,
fieldIDToName: fieldIDToName,
}, nil
Expand Down Expand Up @@ -245,13 +252,13 @@ func (c *Client) CreateTicket(requesterEmail, subject, commentBody string, field
return result.Ticket.ID, nil
}

// FindTickets returns all tickets whose custom fields match the supplied
// matchFields. The matchFields map should contain the display names of the
// custom fields as keys and the desired values as values. The method returns a
// map where the keys are ticket IDs and the values are maps of custom field
// names to their values. If no matchFields are supplied, an error is returned.
// If a custom field name is unknown, an error is returned.
func (c *Client) FindTickets(matchFields map[string]string) (map[int64]map[string]string, error) {
// FindTickets returns all tickets whose custom fields match the required
// matchFields and optional status. The matchFields map should contain the
// display names of the custom fields as keys and the desired values as values.
// The method returns a map where the keys are ticket IDs and the values are
// maps of custom field names to their values. If no matchFields are supplied,
// an error is returned. If a custom field name is unknown, an error is returned.
func (c *Client) FindTickets(matchFields map[string]string, status string) (map[int64]map[string]string, error) {
if len(matchFields) == 0 {
return nil, fmt.Errorf("no match fields supplied")
}
Expand All @@ -262,6 +269,13 @@ func (c *Client) FindTickets(matchFields map[string]string) (map[int64]map[strin

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

if status != "" {
if !slices.Contains(validStatuses, status) {
return nil, fmt.Errorf("invalid status %q, must be one of %s", status, validStatuses)
}
query = append(query, fmt.Sprintf("status:%s", status))
}

for name, want := range matchFields {
id, ok := c.nameToFieldID[name]
if !ok {
Expand Down Expand Up @@ -325,7 +339,7 @@ func (c *Client) FindTickets(matchFields map[string]string) (map[int64]map[strin
// added as a public or private comment based on the provided boolean value. An
// error is returned if the request fails.
func (c *Client) AddComment(ticketID int64, commentBody string, public bool) error {
endpoint, err := url.JoinPath(c.commentURL, fmt.Sprintf("%d.json", ticketID))
endpoint, err := url.JoinPath(c.updateURL, fmt.Sprintf("%d.json", ticketID))
if err != nil {
return fmt.Errorf("failed to join ticket path: %w", err)
}
Expand All @@ -350,3 +364,40 @@ func (c *Client) AddComment(ticketID int64, commentBody string, public bool) err
}
return nil
}

// UpdateTicketStatus updates the status of the specified ticket to the provided
// status and adds a comment with the provided body. The comment is added as a
// public or private comment based on the provided boolean value. An error is
// returned if the request fails or if the provided status is invalid.
func (c *Client) UpdateTicketStatus(ticketID int64, status string, commentBody string, public bool) error {
if !slices.Contains(validStatuses, status) {
return fmt.Errorf("invalid status %q, must be one of %s", status, validStatuses)
}

endpoint, err := url.JoinPath(c.updateURL, fmt.Sprintf("%d.json", ticketID))
if err != nil {
return fmt.Errorf("failed to join ticket path: %w", err)
}

// For more information on the status update format, see:
// https://developer.zendesk.com/api-reference/ticketing/tickets/tickets/#update-ticket
payload := struct {
Ticket struct {
Comment comment `json:"comment"`
Status string `json:"status"`
} `json:"ticket"`
}{}
payload.Ticket.Comment = comment{Body: commentBody, Public: public}
payload.Ticket.Status = status

body, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("failed to marshal zendesk status update: %w", err)
}

_, err = c.doJSONRequest(http.MethodPut, endpoint, body)
if err != nil {
return fmt.Errorf("failed to update zendesk ticket %d: %w", ticketID, err)
}
return nil
}
161 changes: 137 additions & 24 deletions sfe/zendesk/zendesk_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"sync/atomic"
"testing"
Expand Down Expand Up @@ -188,6 +189,134 @@ func TestAddComment404(t *testing.T) {
}
}

func TestAddCommentEmptyBody422(t *testing.T) {
t.Parallel()

c, _ := startMockClient(t)

id, err := c.CreateTicket("a@example.com", "s", "init", nil)
if err != nil {
t.Errorf("CreateTicket(a@example.com): %s", err)
}

err = c.AddComment(id, "", true)
if err == nil || !strings.Contains(err.Error(), "status 422") {
t.Errorf("expected HTTP 422 for empty comment body on ticket %d, got: %s", id, err)
}
}

func TestUpdateTicketStatus(t *testing.T) {
t.Parallel()

type tc struct {
name string
status string
comment *comment
expectErr bool
expectStatus string
expectComment *comment
}

cases := []tc{
{
name: "Update to open without comment",
status: "open",
expectErr: false,
expectStatus: "open",
},
{
name: "Update to pending with comment",
status: "solved",
comment: &comment{Body: "Resolved", Public: true},
expectErr: false,
expectStatus: "solved",
expectComment: &comment{Body: "Resolved", Public: true},
},
{
name: "Update from new to foo (invalid status)",
status: "foo",
expectErr: true,
expectStatus: "new",
},
{
name: "unknown id",
status: "open",
expectErr: true,
expectStatus: "new",
},
}

for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()

fake := zendeskfake.NewServer(apiTokenEmail, apiToken, nil)
ts := httptest.NewServer(fake.Handler())
t.Cleanup(ts.Close)

client, err := NewClient(ts.URL, apiTokenEmail, apiToken, map[string]int64{})
if err != nil {
t.Errorf("Unexpected error from NewClient(%q): %s", ts.URL, err)
}

client.updateURL, err = url.JoinPath(ts.URL, "/api/v2/tickets")
if err != nil {
t.Errorf("Failed to join update URL: %s", err)
}

id, err := client.CreateTicket("foo@bar.co", "Some subject", "Some comment", nil)
if err != nil {
t.Errorf("Unexpected error from CreateTicket: %s", err)
}

updateID := id
if tc.name == "unknown id" {
updateID = 999999
}

var commentBody string
var public bool
if tc.comment != nil {
commentBody = tc.comment.Body
public = tc.comment.Public
}
err = client.UpdateTicketStatus(updateID, tc.status, commentBody, public)
if tc.expectErr {
if err == nil {
t.Errorf("Expected error for status %q, got nil", tc.status)
}
} else {
if err != nil {
t.Errorf("Unexpected error for UpdateTicketStatus(%d, %q): %s", updateID, tc.status, err)
}
}

got, ok := fake.GetTicket(id)
if !ok {
t.Errorf("Ticket with id %d not found after update", id)
}

if got.Status != tc.expectStatus {
t.Errorf("Expected status %q, got %q", tc.expectStatus, got.Status)
}
if tc.expectComment != nil {
found := false
for _, c := range got.Comments {
if c.Body == tc.expectComment.Body && c.Public == tc.expectComment.Public {
found = true
break
}
}
if !found {
t.Errorf("Expected comment not found: %#v in %#v", tc.expectComment, got.Comments)
}
} else if len(got.Comments) > 1 {
t.Errorf("Expected no additional comment, got %d: %#v", len(got.Comments), got.Comments)
}
})
}
}

func TestFindTicketsSimple(t *testing.T) {
t.Parallel()

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

got, err := c.FindTickets(map[string]string{"reviewStatus": "pending"})
got, err := c.FindTickets(map[string]string{"reviewStatus": "pending"}, "new")
if err != nil {
t.Errorf("FindTickets(reviewStatus=pending): %s", err)
}
Expand Down Expand Up @@ -234,7 +363,7 @@ func TestFindTicketsQuotedValueReturnsAll(t *testing.T) {
}
}

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

c, _ := startMockClient(t)

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

c, _ := startMockClient(t)

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

func TestAddCommentEmptyBody422(t *testing.T) {
t.Parallel()

c, _ := startMockClient(t)

id, err := c.CreateTicket("a@example.com", "s", "init", nil)
if err != nil {
t.Errorf("CreateTicket(a@example.com): %s", err)
}

err = c.AddComment(id, "", true)
if err == nil || !strings.Contains(err.Error(), "status 422") {
t.Errorf("expected HTTP 422 for empty comment body on ticket %d, got: %s", id, err)
}
}

func TestFindTicketsNoResults(t *testing.T) {
t.Parallel()

Expand All @@ -290,7 +403,7 @@ func TestFindTicketsNoResults(t *testing.T) {
if err != nil {
t.Errorf("creating ticket with reviewStatus=approved: %s", err)
}
got, err := c.FindTickets(map[string]string{"reviewStatus": "pending"})
got, err := c.FindTickets(map[string]string{"reviewStatus": "pending"}, "new")
if err != nil {
t.Errorf("FindTickets(reviewStatus=pending): %s", err)
}
Expand Down Expand Up @@ -335,7 +448,7 @@ func TestFindTicketsPaginationFollowed(t *testing.T) {
}
}

got, err := c.FindTickets(map[string]string{"reviewStatus": "needs review"})
got, err := c.FindTickets(map[string]string{"reviewStatus": "needs review"}, "")
if err != nil {
t.Errorf("FindTickets(needs review): %s", err)
}
Expand All @@ -362,7 +475,7 @@ func TestFindTicketsHTTP400(t *testing.T) {
if err != nil {
t.Errorf("NewClient(%q): %s", ts.URL, err)
}
_, err = c.FindTickets(map[string]string{"reviewStatus": "needs review"})
_, err = c.FindTickets(map[string]string{"reviewStatus": "needs review"}, "new")
if err == nil || !strings.Contains(err.Error(), "status 400") {
t.Errorf("expected HTTP 400 from search, got: %s", err)
}
Expand All @@ -382,7 +495,7 @@ func TestFindTicketsHTTP500(t *testing.T) {
if err != nil {
t.Errorf("NewClient(%q): %s", ts.URL, err)
}
_, err = c.FindTickets(map[string]string{"reviewStatus": "needs review"})
_, err = c.FindTickets(map[string]string{"reviewStatus": "needs review"}, "new")
if err == nil || !strings.Contains(err.Error(), "status 500") {
t.Errorf("expected HTTP 500 from search, got: %s", err)
}
Expand Down
Loading