Repository: moov-io/customers
Pull request #101 (2020-08-06 00:32:37)

cmd/server: add an endpoint for searching customers

codecov-commenter: # [Codecov](https://codecov.io/gh/moov-io/customers/pull/101?src=pr&el=h1) Report
> Merging [#101](https://codecov.io/gh/moov-io/customers/pull/101?src=pr&el=desc) into [master](https://codecov.io/gh/moov-io/customers/commit/21c02868912413f4c0e359834613ae3420bc7a16&el=desc) will **decrease** coverage by `1.13%`.
> The diff coverage is `21.81%`.


```diff
@@            Coverage Diff             @@
##           master     #101      +/-   ##
==========================================
- Coverage   61.19%   60.06%   -1.14%     
==========================================
  Files          33       34       +1     
  Lines        1853     1908      +55     
==========================================
+ Hits         1134     1146      +12     
- Misses        511      553      +42     
- Partials      208      209       +1     
```


Patch:
diff --git a/cmd/server/customer_search.go b/cmd/server/customer_search.go
new file mode 100644
index 0000000..29d2e94
--- /dev/null
+++ b/cmd/server/customer_search.go
@@ -0,0 +1,105 @@
+// Copyright 2020 The Moov Authors
+// Use of this source code is governed by an Apache License
+// license that can be found in the LICENSE file.
+
+package main
+
+import (
+	"encoding/json"
+	"fmt"
+	"net/http"
+	"net/url"
+	"strconv"
+	"strings"
+
+	moovhttp "github.com/moov-io/base/http"
+	client "github.com/moov-io/customers/client"
+	"github.com/moov-io/customers/cmd/server/route"
+	"github.com/moov-io/customers/internal/util"
+
+	"github.com/go-kit/kit/log"
+)
+
+func searchCustomers(logger log.Logger, repo customerRepository) http.HandlerFunc {
+	return func(w http.ResponseWriter, r *http.Request) {
+		w = route.Responder(logger, w, r)
+
+		params := readSearchParams(r.URL)
+		customers, err := repo.searchCustomers(params)
+		if err != nil {
+			moovhttp.Problem(w, err)
+			return
+		}
+
+		logger.Log("customers", fmt.Sprintf("found %d customers in search", len(customers)))
+
+		w.Header().Set("Content-Type", "application/json; charset=utf-8")
+		w.WriteHeader(http.StatusOK)
+		json.NewEncoder(w).Encode(customers)
+	}
+}
+
+type searchParams struct {
+	Query string
+	Email string
+	Limit int64
+}
+
+func readSearchParams(u *url.URL) searchParams {
+	params := searchParams{
+		Query: strings.ToLower(strings.TrimSpace(u.Query().Get("query"))),
+		Email: strings.ToLower(strings.TrimSpace(u.Query().Get("email"))),
+	}
+	if limit, err := strconv.ParseInt(util.Or(u.Query().Get("limit"), "20"), 10, 32); err == nil {
+		params.Limit = limit
+	}
+	return params
+}
+
+func (r *sqlCustomerRepository) searchCustomers(params searchParams) ([]*client.Customer, error) {
+	query, args := buildSearchQuery(params)
+
+	stmt, err := r.db.Prepare(query)
+	if err != nil {
+		return nil, err
+	}
+	defer stmt.Close()
+
+	var customerIDs []string
+	rows, err := stmt.Query(args...)
+	if err != nil {
+		return nil, err
+	}
+	for rows.Next() {
+		var customerID string
+		if err := rows.Scan(&customerID); err == nil {
+			customerIDs = append(customerIDs, customerID)
+		} else {
+			return nil, err
+		}
+	}
+
+	customers := make([]*client.Customer, 0)
+	for i := range customerIDs {
+		cust, err := r.getCustomer(customerIDs[i])
+		if err != nil {
+			return nil, fmt.Errorf("search: customerID=%s error=%v", customerIDs[i], err)
+		}
+		customers = append(customers, cust)
+	}
+	return customers, nil
+}
+
+func buildSearchQuery(params searchParams) (string, []interface{}) {
+	var args []interface{}
+	query := `select customer_id from customers where deleted_at is null`
+	if params.Query != "" {
+		query += " and lower(first_name) || \" \" || lower(last_name) LIKE ?"
+		args = append(args, "%"+strings.ToLower(params.Query)+"%")
+	}
+	if params.Email != "" {
+		query += " and lower(email) like ?"
+		args = append(args, "%"+strings.ToLower(params.Email))
+	}
+	return query + " order by created_at asc limit ?;", append(args, fmt.Sprintf("%d", params.Limit))
+}
diff --git a/cmd/server/customers.go b/cmd/server/customers.go
index 570eb72..71420f0 100644
--- a/cmd/server/customers.go
+++ b/cmd/server/customers.go
@@ -25,6 +25,7 @@ import (
 )
 
 func addCustomerRoutes(logger log.Logger, r *mux.Router, repo customerRepository, customerSSNStorage *ssnStorage, ofac *ofacSearcher) {
+	r.Methods("GET").Path("/customers").HandlerFunc(searchCustomers(logger, repo))
 	r.Methods("GET").Path("/customers/{customerID}").HandlerFunc(getCustomer(logger, repo))
 	r.Methods("POST").Path("/customers").HandlerFunc(createCustomer(logger, repo, customerSSNStorage, ofac))
 	r.Methods("PUT").Path("/customers/{customerID}/metadata").HandlerFunc(replaceCustomerMetadata(logger, repo))
@@ -313,6 +314,8 @@ type customerRepository interface {
 	createCustomer(c *client.Customer) error
 	updateCustomerStatus(customerID string, status client.CustomerStatus, comment string) error
 
+	searchCustomers(params searchParams) ([]*client.Customer, error)
+
 	getCustomerMetadata(customerID string) (map[string]string, error)
 	replaceCustomerMetadata(customerID string, metadata map[string]string) error
 


Test patch:
diff --git a/cmd/server/customer_search_test.go b/cmd/server/customer_search_test.go
new file mode 100644
index 0000000..25b4c81
--- /dev/null
+++ b/cmd/server/customer_search_test.go
@@ -0,0 +1,144 @@
+// Copyright 2020 The Moov Authors
+// Use of this source code is governed by an Apache License
+// license that can be found in the LICENSE file.
+
+package main
+
+import (
+	"database/sql"
+	"encoding/json"
+	"net/http"
+	"net/http/httptest"
+	"testing"
+
+	"github.com/go-kit/kit/log"
+	"github.com/gorilla/mux"
+	client "github.com/moov-io/customers/client"
+	"github.com/moov-io/customers/internal/database"
+)
+
+func TestCustomersSearchRouter(t *testing.T) {
+	repo := createTestCustomerRepository(t)
+	defer repo.close()
+
+	router := mux.NewRouter()
+	addCustomerRoutes(log.NewNopLogger(), router, repo, nil, nil)
+
+	w := httptest.NewRecorder()
+	req := httptest.NewRequest("GET", "/customers?query=jane+doe", nil)
+	router.ServeHTTP(w, req)
+
+	// verify with zero results we don't return null
+	if body := w.Body.String(); body != "[]\n" {
+		t.Errorf("got %q", body)
+	}
+	if w.Code != http.StatusOK {
+		t.Errorf("bogus HTTP status: %v", w.Code)
+	}
+
+	// write a customer we can search for
+	cust, _, _ := (customerRequest{
+		FirstName: "Jane",
+		LastName:  "Doe",
+		Email:     "jane@example.com",
+	}).asCustomer(testCustomerSSNStorage(t))
+	if err := repo.createCustomer(cust); err != nil {
+		t.Error(err)
+	}
+
+	// find a customer from their partial name
+	w = httptest.NewRecorder()
+	req = httptest.NewRequest("GET", "/customers?query=jane", nil)
+	router.ServeHTTP(w, req)
+
+	var resp []*client.Customer
+	if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
+		t.Fatal(err)
+	}
+	if len(resp) != 1 || resp[0].CustomerID != cust.CustomerID {
+		t.Errorf("unexpected customers: %#v", resp)
+	}
+
+	// find a customer from full name
+	w = httptest.NewRecorder()
+	req = httptest.NewRequest("GET", "/customers?query=jane+doe", nil)
+	router.ServeHTTP(w, req)
+
+	var resp2 []*client.Customer
+	if err := json.NewDecoder(w.Body).Decode(&resp2); err != nil {
+		t.Fatal(err)
+	}
+	if len(resp2) != 1 || resp2[0].CustomerID != cust.CustomerID {
+		t.Errorf("unexpected customers: %#v", resp2)
+	}
+}
+
+func TestCustomerSearch__query(t *testing.T) {
+	sqliteDB := database.CreateTestSqliteDB(t)
+	defer sqliteDB.Close()
+
+	mysqlDB := database.CreateTestMySQLDB(t)
+	defer mysqlDB.Close()
+
+	prepare := func(db *sql.DB, query string) error {
+		stmt, err := db.Prepare(query)
+		if err != nil {
+			return err
+		}
+		return stmt.Close()
+	}
+
+	// Query search
+	query, args := buildSearchQuery(searchParams{
+		Query: "jane doe",
+		Limit: 100,
+	})
+	if query != "select customer_id from customers where deleted_at is null and lower(first_name) || \" \" || lower(last_name) LIKE ? order by created_at asc limit ?;" {
+		t.Errorf("unexpected query: %q", query)
+	}
+	if err := prepare(sqliteDB.DB, query); err != nil {
+		t.Errorf("sqlite: %v", err)
+	}
+	if err := prepare(mysqlDB.DB, query); err != nil {
+		t.Errorf("mysql: %v", err)
+	}
+	if len(args) != 2 {
+		t.Errorf("unexpected args: %#v", args)
+	}
+
+	// Eamil search
+	query, args = buildSearchQuery(searchParams{
+		Email: "jane.doe@moov.io",
+	})
+	if query != "select customer_id from customers where deleted_at is null and lower(email) like ? order by created_at asc limit ?;" {
+		t.Errorf("unexpected query: %q", query)
+	}
+	if err := prepare(sqliteDB.DB, query); err != nil {
+		t.Errorf("sqlite: %v", err)
+	}
+	if err := prepare(mysqlDB.DB, query); err != nil {
+		t.Errorf("mysql: %v", err)
+	}
+	if len(args) != 2 {
+		t.Errorf("unexpected args: %#v", args)
+	}
+
+	// Query and Eamil saerch
+	query, args = buildSearchQuery(searchParams{
+		Query: "jane doe",
+		Email: "jane.doe@moov.io",
+		Limit: 25,
+	})
+	if query != "select customer_id from customers where deleted_at is null and lower(first_name) || \" \" || lower(last_name) LIKE ? and lower(email) like ? order by created_at asc limit ?;" {
+		t.Errorf("unexpected query: %q", query)
+	}
+	if err := prepare(sqliteDB.DB, query); err != nil {
+		t.Errorf("sqlite: %v", err)
+	}
+	if err := prepare(mysqlDB.DB, query); err != nil {
+		t.Errorf("mysql: %v", err)
+	}
+	if len(args) != 3 {
+		t.Errorf("unexpected args: %#v", args)
+	}
+}
diff --git a/cmd/server/customers_test.go b/cmd/server/customers_test.go
index 5a7f36b..af26a28 100644
--- a/cmd/server/customers_test.go
+++ b/cmd/server/customers_test.go
@@ -51,6 +51,16 @@ func (r *testCustomerRepository) updateCustomerStatus(customerID string, status
 	return r.err
 }
 
+func (r *testCustomerRepository) searchCustomers(params searchParams) ([]*client.Customer, error) {
+	if r.err != nil {
+		return nil, r.err
+	}
+	if r.customer != nil {
+		return []*client.Customer{r.customer}, nil
+	}
+	return nil, nil
+}
+
 func (r *testCustomerRepository) getCustomerMetadata(customerID string) (map[string]string, error) {
 	out := make(map[string]string)
 	return out, r.err