diff --git a/TODO.txt b/TODO.txt deleted file mode 100644 index 5161e28..0000000 --- a/TODO.txt +++ /dev/null @@ -1,2 +0,0 @@ -- Create List -- Reorder Cards in List diff --git a/card.go b/card.go index f7ccf8c..0818093 100644 --- a/card.go +++ b/card.go @@ -43,6 +43,7 @@ type Card struct { ShortURL string `json:"shortUrl"` URL string `json:"url"` Desc string `json:"desc"` + Start *time.Time `json:"start"` Due *time.Time `json:"due"` DueComplete bool `json:"dueComplete"` Closed bool `json:"closed"` @@ -275,6 +276,9 @@ func (c *Client) CreateCard(card *Card, extraArgs ...Arguments) error { if card.Due != nil { args["due"] = card.Due.Format(time.RFC3339) } + if card.Start != nil { + args["start"] = card.Start.Format(time.RFC3339) + } args.flatten(extraArgs) err := c.Post(path, args, &card) @@ -296,6 +300,9 @@ func (l *List) AddCard(card *Card, extraArgs ...Arguments) error { if card.Due != nil { args["due"] = card.Due.Format(time.RFC3339) } + if card.Start != nil { + args["start"] = card.Start.Format(time.RFC3339) + } args.flatten(extraArgs) diff --git a/card_test.go b/card_test.go index 524c814..5fb9af8 100644 --- a/card_test.go +++ b/card_test.go @@ -6,6 +6,7 @@ package trello import ( + "net/http" "testing" "time" ) @@ -25,10 +26,10 @@ func TestCardCreatedAt(t *testing.T) { func TestGetCardsOnBoard(t *testing.T) { board := testBoard(t) - server := mockDynamicPathResponse() + server := NewMockResponder(t) defer server.Close() - board.client.BaseURL = server.URL + board.client.BaseURL = server.URL() cards, err := board.GetCards(Defaults()) if err != nil { t.Fatal(err) @@ -41,9 +42,9 @@ func TestGetCardsOnBoard(t *testing.T) { func TestGetCardsInList(t *testing.T) { list := testList(t) - server := mockResponse("cards", "list-cards-api-example.json") + server := NewMockResponder(t, "cards", "list-cards-api-example.json") defer server.Close() - list.client.BaseURL = server.URL + list.client.BaseURL = server.URL() cards, err := list.GetCards(Defaults()) if err != nil { @@ -57,9 +58,9 @@ func TestGetCardsInList(t *testing.T) { func TestCardsCustomFields(t *testing.T) { list := testList(t) - server := mockResponse("cards", "list-cards-api-example.json") + server := NewMockResponder(t, "cards", "list-cards-api-example.json") defer server.Close() - list.client.BaseURL = server.URL + list.client.BaseURL = server.URL() cards, err := list.GetCards(Defaults()) if err != nil { @@ -94,10 +95,10 @@ func TestCardsCustomFields(t *testing.T) { func TestBoardContainsCopyOfCard(t *testing.T) { board := testBoard(t) - server := mockResponse("actions", "board-actions-copyCard.json") + server := NewMockResponder(t, "actions", "board-actions-copyCard.json") defer server.Close() - board.client.BaseURL = server.URL + board.client.BaseURL = server.URL() firstResult, err := board.ContainsCopyOfCard("57f50c552b96e3fffe588aad", Defaults()) if err != nil { t.Error(err) @@ -117,16 +118,29 @@ func TestBoardContainsCopyOfCard(t *testing.T) { func TestCreateCard(t *testing.T) { c := testClient() - server := mockResponse("cards", "card-create.json") + server := NewMockResponder(t, "cards", "card-create.json") defer server.Close() - - c.BaseURL = server.URL + server.AssertRequest(func(t *testing.T, r *http.Request) { + due := r.URL.Query().Get("due") + if _, err := time.Parse(time.RFC3339, due); err != nil { + t.Errorf("Expected due to be in RFC3339 format, but value was '%v'", due) + } + + start := r.URL.Query().Get("start") + if _, err := time.Parse(time.RFC3339, start); err != nil { + t.Errorf("Expected start to be in RFC3339 format, but value was '%v'", start) + } + }) + + c.BaseURL = server.URL() dueDate := time.Now().AddDate(0, 0, 3) + startDate := time.Now().AddDate(0, 0, 2) card := Card{ Name: "Test Card Create", Desc: "What its about", Due: &dueDate, + Start: &startDate, IDList: "57f03a06b5ff33a63c8be316", IDLabels: []string{"label1", "label2"}, } @@ -156,15 +170,28 @@ func TestCreateCard(t *testing.T) { func TestAddCardToList(t *testing.T) { l := testList(t) - server := mockResponse("cards", "card-posted-to-bottom-of-list.json") + server := NewMockResponder(t, "cards", "card-posted-to-bottom-of-list.json") + server.AssertRequest(func(t *testing.T, r *http.Request) { + due := r.URL.Query().Get("due") + if _, err := time.Parse(time.RFC3339, due); err != nil { + t.Errorf("Expected due to be in RFC3339 format, but value was '%v'", due) + } + + start := r.URL.Query().Get("start") + if _, err := time.Parse(time.RFC3339, start); err != nil { + t.Errorf("Expected start to be in RFC3339 format, but value was '%v'", start) + } + }) defer server.Close() - l.client.BaseURL = server.URL - dueDate := time.Now().AddDate(0, 0, 1) + l.client.BaseURL = server.URL() + dueDate := time.Now().AddDate(0, 0, 2) + startDate := time.Now().AddDate(0, 0, 1) card := Card{ Name: "Test Card POSTed to List", Desc: "This is its description.", Due: &dueDate, + Start: &startDate, IDLabels: []string{"label1", "label2"}, } @@ -193,16 +220,16 @@ func TestAddCardToList(t *testing.T) { func TestArchiveUnarchive(t *testing.T) { c := testCard(t) - server := mockResponse("cards", "card-archived.json") - c.client.BaseURL = server.URL + server := NewMockResponder(t, "cards", "card-archived.json") + c.client.BaseURL = server.URL() c.Archive() if c.Closed == false { t.Errorf("Card should have been archived.") } server.Close() - server = mockResponse("cards", "card-unarchived.json") - c.client.BaseURL = server.URL + server = NewMockResponder(t, "cards", "card-unarchived.json") + c.client.BaseURL = server.URL() c.Unarchive() if c.Closed == true { t.Errorf("Card should have been unarchived.") @@ -213,9 +240,9 @@ func TestArchiveUnarchive(t *testing.T) { func TestCopyCardToList(t *testing.T) { c := testCard(t) - server := mockResponse("cards", "card-copied.json") + server := NewMockResponder(t, "cards", "card-copied.json") defer server.Close() - c.client.BaseURL = server.URL + c.client.BaseURL = server.URL() newCard, err := c.CopyToList("57f03a022cd45c863ca581f1", Defaults()) if err != nil { @@ -234,9 +261,9 @@ func TestCopyCardToList(t *testing.T) { func TestGetParentCard(t *testing.T) { c := testCard(t) - server := mockDynamicPathResponse() + server := NewMockResponder(t) defer server.Close() - c.client.BaseURL = server.URL + c.client.BaseURL = server.URL() parent, err := c.GetParentCard(Defaults()) if err != nil { @@ -265,10 +292,10 @@ func TestGetAncestorCards(t *testing.T) { func TestAddMemberIdToCard(t *testing.T) { c := testCard(t) - server := mockResponse("cards", "card-add-member-response.json") + server := NewMockResponder(t, "cards", "card-add-member-response.json") defer server.Close() - c.client.BaseURL = server.URL + c.client.BaseURL = server.URL() member, err := c.AddMemberID("testmemberid") if err != nil { t.Error(err) @@ -283,10 +310,10 @@ func TestAddMemberIdToCard(t *testing.T) { func TestAddURLAttachmentToCard(t *testing.T) { c := testCard(t) - server := mockResponse("cards", "url-attachments.json") + server := NewMockResponder(t, "cards", "url-attachments.json") defer server.Close() - c.client.BaseURL = server.URL + c.client.BaseURL = server.URL() attachment := Attachment{ Name: "Test", URL: "https://github.com/test", @@ -313,10 +340,10 @@ func TestCardSetClient(t *testing.T) { // func testCard(t *testing.T) *Card { c := testClient() - server := mockResponse("cards", "card-api-example.json") + server := NewMockResponder(t, "cards", "card-api-example.json") defer server.Close() - c.BaseURL = server.URL + c.BaseURL = server.URL() card, err := c.GetCard("4eea503", Defaults()) if err != nil { t.Fatal(err) diff --git a/mock-responder_test.go b/mock-responder_test.go new file mode 100644 index 0000000..51c371e --- /dev/null +++ b/mock-responder_test.go @@ -0,0 +1,153 @@ +package trello + +import ( + "crypto/md5" + "fmt" + "io/ioutil" + "log" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +// MockResponder is a thin wrapper around the httptest.Server. It adds the +// ability to specify a file or directory of files to use as mock responses, +// and provides the AsertRequest method to add test assertions that requests +// are being made correctly. Just like with httptest.Server, the caller should +// defer a call to .Close() to shutdown the server when all requests complete. +// MockResponders should be created via the NewMockResponder() constructor. +// +type MockResponder interface { + Close() + URL() string + AssertRequest(func(t *testing.T, r *http.Request)) +} + +type mockResponder struct { + t *testing.T + + // server will be nil until .URL() is called the first time + server *httptest.Server + + // requestAssertions is a list of functions which is called on + // each HTTP request before finding and returning the mock response content. + // They should be used to make assertions on the contents of the HTTP + // request being made against this MockResponder + requestAssertions []func(t *testing.T, r *http.Request) + + // mockPath holds the results of filepath.Join on the provided path parts. + // The constructor verifies the existence of the path, so this will always + // hold a valid path to either a mock file, or a directory of many mocks + mockPath string + + // useDynamicPaths is set to true when mockPath is a directory. It triggers + // code which determies the mock file from the path of incoming HTTP + // requests + useDynamicPaths bool +} + +// NewMockResponder creates a new MockResponder instance around the provided +// test case. The mockPath is the relative filesystem path under ./testdata/ +// where the mock response JSON can be found. +// +// If mockPath describes the path to a *file*, then that file +// will be used for ALL requests. If the path is a directory, then the mock +// response will be built dynamically from the path of the request (e.g. +// GET /subdir/folder/file will return the file at subdir/folder/file.json, +// assuming it exists). This latter mode is described as "dynamic paths". When +// requests arrive with querystring arguments, the dynamic path builder will +// compute an MD5 hash of the arguments and include that as a suffix of the +// mock file path. +// +// If no mockPath is provided, then the MockResponder will run in dynamic path +// mode from the root of the testdata/ directory. +// +// The caller is expected to defer a call .Close() after NewMockResponder(). +// +func NewMockResponder(t *testing.T, mockPath ...string) MockResponder { + r := &mockResponder{t: t} + + // Verify a valid path was provided + r.mockPath = filepath.Join(append([]string{".", "testdata"}, mockPath...)...) + fi, err := os.Stat(r.mockPath) + if err != nil { + log.Fatalf("invalid mock path %v: %s", mockPath, err) + } + + // If the provided mockPath points to a directory, then + // we'll figure out the ultimate path dynamically as requests occur. + r.useDynamicPaths = fi.IsDir() + + return r +} + +// AssertRequest adds a new function to be run on each HTTP request the mock +// responder recveives. Its intended use is to make test assertions on the +// content of the request +func (mr *mockResponder) AssertRequest(ra func(t *testing.T, r *http.Request)) { + mr.requestAssertions = append(mr.requestAssertions, ra) +} + +// Close wraps the *httptest.Server's Close method +func (mr *mockResponder) Close() { + if mr.server != nil { + mr.server.Close() + } +} + +// URL is equivalent to the *httptest.Server property of the same name, but it +// is responsible for *creating* the *httptest.Server. This function should +// be called after all customization (including calls to AssertRequest) is +// complete. +// +func (mr *mockResponder) URL() string { + if mr.server != nil { + mr.t.Error("URL() should only be called once, after completing configuration") + } + mr.server = httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + for _, assertion := range mr.requestAssertions { + assertion(mr.t, r) + } + mr.mockHandler(rw, r) + })) + return mr.server.URL +} + +// mockHandler is the http.HandlerFunc for the httptest.Server inside the +// mockResponder. When the mockPath points to a single file, it simply returns +// that file in the HTTP response. Otherwise it dynamically determines the +// path of the mock file to use and returns that if the file is found... +// otherwise it responds with an error instructing the user where to put their +// mock file. +// +func (mr *mockResponder) mockHandler(rw http.ResponseWriter, r *http.Request) { + var filename string + if mr.useDynamicPaths { + parts := []string{mr.mockPath} + parts = append(parts, strings.Split(strings.TrimPrefix(r.URL.Path, "/"), "/")...) + queryStringPart := strings.Replace(r.URL.RawQuery, "key=user&token=pass", "", -1) + if queryStringPart != "" { + parts[len(parts)-1] = fmt.Sprintf("%s-%x", parts[len(parts)-1], md5.Sum([]byte(queryStringPart))) + } + + filename = filepath.Join(parts...) + if !strings.HasSuffix(filename, ".json") { + filename = filename + ".json" + } + if _, err := os.Stat(filename); err != nil { + http.Error(rw, fmt.Sprintf("%s doesn't exist or couldn't be read. Create it with the mock you'd like to use.\n Args were: %s", filename, queryStringPart), http.StatusNotFound) + return + } + } else { + filename = mr.mockPath + } + + mockData, err := ioutil.ReadFile(filename) + if err != nil { + log.Fatal(err) + } + rw.Write(mockData) +}