Repository: moov-io/customers (go)

Issue:
customers: add filter for customerIDs in searchCustomers repository method
`searchCustomers` should be able to filter its results based on a list of `customerIDs`.

Use case: https://github.com/moov-io/customers/pull/211/files#diff-ca31bb1058e5748103757f7a72dfaa10R50

Pull request description:
Enable searching customers by their IDs and optimize # of database queries
This PR makes two main changes:

1. Adds the ability to search by customerIDs: `GET /customers?customerIDs=id_1,id_2`

2. Reduces the number of database queries triggered by `GET /customers`
A call to `GET /customers` that matches `n=25` customers would trigger `~100 or n*4` database queries:
`searchCustomers` would call `getCustomer()` `n` times 
   - each call to `getCustomer()` would also trigger 3 additional db queries to fetch the customer's addresses, phones, and metadata.
Thus, `n * ( getCustomer() + getAddresses() + getPhones() + getMetadata() ) -> 25 * (1+1+1+1) = 100` 



Fixes #220 #216

Patch:
diff --git a/api/client.yaml b/api/client.yaml
index 57fcb93..767cfe2 100644
--- a/api/client.yaml
+++ b/api/client.yaml
@@ -206,10 +206,16 @@ paths:
             type: string
         - name: count
           in: query
-          description: Optional parameter for searching for customers by specifying the amount to return
+          description: Optional parameter for searching by specifying the amount to return
           example: 20
           schema:
             type: string
+        - name: customerIDs
+          in: query
+          description: Optional parameter for searching by customers' IDs
+          example: e210a9d6-d755-4455-9bd2-9577ea7e1081,970ef15d-a4e1-473f-b5d7-da38163b0ba3
+          schema:
+            type: string
       responses:
         '200':
           description: Customers were successfully retrieved
diff --git a/pkg/client/api/openapi.yaml b/pkg/client/api/openapi.yaml
index 7d605ac..f842cc6 100644
--- a/pkg/client/api/openapi.yaml
+++ b/pkg/client/api/openapi.yaml
@@ -230,8 +230,8 @@ paths:
         schema:
           type: string
         style: form
-      - description: Optional parameter for searching for customers by specifying
-          the amount to return
+      - description: Optional parameter for searching by specifying the amount to
+          return
         example: 20
         explode: true
         in: query
@@ -240,6 +240,15 @@ paths:
         schema:
           type: string
         style: form
+      - description: Optional parameter for searching by customers' IDs
+        example: e210a9d6-d755-4455-9bd2-9577ea7e1081,970ef15d-a4e1-473f-b5d7-da38163b0ba3
+        explode: true
+        in: query
+        name: customerIDs
+        required: false
+        schema:
+          type: string
+        style: form
       responses:
         "200":
           content:
diff --git a/pkg/client/api_customers.go b/pkg/client/api_customers.go
index 24e6677..9cf55ef 100644
--- a/pkg/client/api_customers.go
+++ b/pkg/client/api_customers.go
@@ -2517,12 +2517,13 @@ func (a *CustomersApiService) ReplaceCustomerMetadata(ctx _context.Context, cust
 
 // SearchCustomersOpts Optional parameters for the method 'SearchCustomers'
 type SearchCustomersOpts struct {
-	Query  optional.String
-	Email  optional.String
-	Status optional.String
-	Type_  optional.String
-	Skip   optional.String
-	Count  optional.String
+	Query       optional.String
+	Email       optional.String
+	Status      optional.String
+	Type_       optional.String
+	Skip        optional.String
+	Count       optional.String
+	CustomerIDs optional.String
 }
 
 /*
@@ -2535,7 +2536,8 @@ Search for customers using different filter parameters
  * @param "Status" (optional.String) -  Optional parameter for searching by customer status
  * @param "Type_" (optional.String) -  Optional parameter for searching by customer type
  * @param "Skip" (optional.String) -  Optional parameter for searching for customers by skipping over an initial group
- * @param "Count" (optional.String) -  Optional parameter for searching for customers by specifying the amount to return
+ * @param "Count" (optional.String) -  Optional parameter for searching by specifying the amount to return
+ * @param "CustomerIDs" (optional.String) -  Optional parameter for searching by customers' IDs
 @return []Customer
 */
 func (a *CustomersApiService) SearchCustomers(ctx _context.Context, localVarOptionals *SearchCustomersOpts) ([]Customer, *_nethttp.Response, error) {
@@ -2572,6 +2574,9 @@ func (a *CustomersApiService) SearchCustomers(ctx _context.Context, localVarOpti
 	if localVarOptionals != nil && localVarOptionals.Count.IsSet() {
 		localVarQueryParams.Add("count", parameterToString(localVarOptionals.Count.Value(), ""))
 	}
+	if localVarOptionals != nil && localVarOptionals.CustomerIDs.IsSet() {
+		localVarQueryParams.Add("customerIDs", parameterToString(localVarOptionals.CustomerIDs.Value(), ""))
+	}
 	// to determine the Content-Type header
 	localVarHTTPContentTypes := []string{}
 
diff --git a/pkg/client/docs/CustomersApi.md b/pkg/client/docs/CustomersApi.md
index 5fccbaf..e50e9ee 100644
--- a/pkg/client/docs/CustomersApi.md
+++ b/pkg/client/docs/CustomersApi.md
@@ -1218,7 +1218,8 @@ Name | Type | Description  | Notes
  **status** | **optional.String**| Optional parameter for searching by customer status | 
  **type_** | **optional.String**| Optional parameter for searching by customer type | 
  **skip** | **optional.String**| Optional parameter for searching for customers by skipping over an initial group | 
- **count** | **optional.String**| Optional parameter for searching for customers by specifying the amount to return | 
+ **count** | **optional.String**| Optional parameter for searching by specifying the amount to return | 
+ **customerIDs** | **optional.String**| Optional parameter for searching by customers&#39; IDs | 
 
 ### Return type
 
diff --git a/pkg/customers/customer_search.go b/pkg/customers/customer_search.go
index 68d2267..45e8824 100644
--- a/pkg/customers/customer_search.go
+++ b/pkg/customers/customer_search.go
@@ -5,6 +5,7 @@
 package customers
 
 import (
+	"database/sql"
 	"encoding/json"
 	"fmt"
 	"net/http"
@@ -13,6 +14,7 @@ import (
 	moovhttp "github.com/moov-io/base/http"
 
 	"github.com/moov-io/base/log"
+
 	"github.com/moov-io/customers/pkg/client"
 	"github.com/moov-io/customers/pkg/route"
 )
@@ -26,7 +28,7 @@ func searchCustomers(logger log.Logger, repo CustomerRepository) http.HandlerFun
 			return
 		}
 
-		params, err := readSearchParams(r)
+		params, err := parseSearchParams(r)
 		if err != nil {
 			moovhttp.Problem(w, err)
 			return
@@ -55,15 +57,25 @@ type SearchParams struct {
 	Type         string
 	Skip         int64
 	Count        int64
+	CustomerIDs  []string
 }
 
-func readSearchParams(r *http.Request) (SearchParams, error) {
+func parseSearchParams(r *http.Request) (SearchParams, error) {
+	queryParams := r.URL.Query()
+	getQueryParam := func(key string) string {
+		return strings.ToLower(strings.TrimSpace(queryParams.Get(key)))
+	}
 	params := SearchParams{
-		Query:  strings.ToLower(strings.TrimSpace(r.URL.Query().Get("query"))),
-		Email:  strings.ToLower(strings.TrimSpace(r.URL.Query().Get("email"))),
-		Status: strings.ToLower(strings.TrimSpace(r.URL.Query().Get("status"))),
-		Type:   strings.ToLower(strings.TrimSpace(r.URL.Query().Get("type"))),
+		Query:  getQueryParam("query"),
+		Email:  getQueryParam("email"),
+		Status: getQueryParam("status"),
+		Type:   getQueryParam("type"),
+	}
+	customerIDsInput := getQueryParam("customerIDs")
+	if customerIDsInput != "" {
+		params.CustomerIDs = strings.Split(customerIDsInput, ",")
 	}
+
 	skip, count, exists, err := moovhttp.GetSkipAndCount(r)
 	if exists && err != nil {
 		return params, err
@@ -84,54 +96,103 @@ func (r *sqlCustomerRepository) searchCustomers(params SearchParams) ([]*client.
 	}
 	defer stmt.Close()
 
-	var customerIDs []string
 	customers := make([]*client.Customer, 0)
-
-	// grab customerIDs
 	rows, err := stmt.Query(args...)
 	if err != nil {
-		return customers, err
+		return nil, err
 	}
 	for rows.Next() {
-		var customerID string
-		if err := rows.Scan(&customerID); err == nil {
-			customerIDs = append(customerIDs, customerID)
-		} else {
-			return customers, err
+		var c client.Customer
+		var birthDate *string
+		err := rows.Scan(
+			&c.CustomerID,
+			&c.FirstName,
+			&c.MiddleName,
+			&c.LastName,
+			&c.NickName,
+			&c.Suffix,
+			&c.Type,
+			&birthDate,
+			&c.Status,
+			&c.Email,
+			&c.CreatedAt,
+			&c.LastModified,
+		)
+		if err != nil {
+			return nil, err
 		}
+		customers = append(customers, &c)
 	}
 
-	// Read each Customer
-	for i := range customerIDs {
-		cust, err := r.GetCustomer(customerIDs[i])
-		if err != nil {
-			return customers, fmt.Errorf("search: customerID=%s error=%v", customerIDs[i], err)
-		}
-		customers = append(customers, cust)
+	if len(customers) == 0 {
+		return customers, nil
+	}
+
+	var customerIDs []string
+	for _, c := range customers {
+		customerIDs = append(customerIDs, c.CustomerID)
+	}
+
+	phonesByCustomerID, err := r.getPhones(customerIDs)
+	if err != nil {
+		return nil, fmt.Errorf("fetching customer phones: %v", err)
+	}
+	addressesByCustomerID, err := r.getAddresses(customerIDs)
+	if err != nil {
+		return nil, fmt.Errorf("fetching customer addresses: %v", err)
+	}
+	metadataByCustomerID, err := r.getMetadata(customerIDs)
+	if err != nil {
+		return nil, fmt.Errorf("fetching customer metadata: %v", err)
 	}
+
+	for _, c := range customers {
+		c.Phones = phonesByCustomerID[c.CustomerID]
+		c.Addresses = addressesByCustomerID[c.CustomerID]
+		c.Metadata = metadataByCustomerID[c.CustomerID].Metadata
+	}
+
 	return customers, nil
 }
 
 func buildSearchQuery(params SearchParams) (string, []interface{}) {
 	var args []interface{}
-	query := `select customer_id from customers where deleted_at is null and organization = ?`
-	args = append(args, params.Organization)
+	query := `select customer_id, first_name, middle_name, last_name, nick_name, suffix, type, birth_date, status, email, created_at, last_modified
+from customers where deleted_at is null`
+
+	if params.Organization != "" {
+		query += " and organization = ?"
+		args = append(args, params.Organization)
+	}
+
 	if params.Query != "" {
-		query += " and lower(first_name) || \" \" || lower(last_name) LIKE ?"
-		args = append(args, "%"+params.Query+"%")
+		// warning: this will ONLY work for MySQL
+		query += " and lower(concat(first_name,' ', last_name)) LIKE ?"
+		args = append(args, fmt.Sprintf("%%%s%%", params.Query))
 	}
+
 	if params.Email != "" {
 		query += " and lower(email) like ?"
 		args = append(args, "%"+params.Email)
 	}
+
 	if params.Status != "" {
 		query += " and status like ?"
 		args = append(args, "%"+params.Status)
 	}
+
 	if params.Type != "" {
 		query += " and type like ?"
 		args = append(args, "%"+params.Type)
 	}
+
+	if len(params.CustomerIDs) > 0 {
+		query += fmt.Sprintf(" and customer_id in (?%s)", strings.Repeat(",?", len(params.CustomerIDs)-1))
+		for _, id := range params.CustomerIDs {
+			args = append(args, id)
+		}
+	}
+
 	query += " order by created_at desc limit ?"
 	args = append(args, fmt.Sprintf("%d", params.Count))
 
@@ -142,3 +203,116 @@ func buildSearchQuery(params SearchParams) (string, []interface{}) {
 	query += ";"
 	return query, args
 }
+
+func (r *sqlCustomerRepository) getPhones(customerIDs []string) (map[string][]client.Phone, error) {
+	query := fmt.Sprintf(
+		"select customer_id, number, valid, type from customers_phones where customer_id in (?%s)",
+		strings.Repeat(",?", len(customerIDs)-1),
+	)
+
+	rows, err := r.queryRowsByCustomerIDs(query, customerIDs)
+	if err != nil {
+		return nil, err
+	}
+	defer rows.Close()
+
+	ret := make(map[string][]client.Phone)
+	for rows.Next() {
+		var p client.Phone
+		var customerID string
+		err := rows.Scan(
+			&customerID,
+			&p.Number,
+			&p.Valid,
+			&p.Type,
+		)
+		if err != nil {
+			return nil, fmt.Errorf("scanning row: %v", err)
+		}
+		ret[customerID] = append(ret[customerID], p)
+	}
+
+	return ret, nil
+}
+
+func (r *sqlCustomerRepository) getAddresses(customerIDs []string) (map[string][]client.CustomerAddress, error) {
+	query := fmt.Sprintf(
+		"select customer_id, address_id, type, address1, address2, city, state, postal_code, country, validated from customers_addresses where customer_id in (?%s) and deleted_at is null;",
+		strings.Repeat(",?", len(customerIDs)-1),
+	)
+	rows, err := r.queryRowsByCustomerIDs(query, customerIDs)
+	if err != nil {
+		return nil, err
+	}
+	defer rows.Close()
+
+	ret := make(map[string][]client.CustomerAddress)
+	for rows.Next() {
+		var a client.CustomerAddress
+		var customerID string
+		if err := rows.Scan(
+			&customerID,
+			&a.AddressID,
+			&a.Type,
+			&a.Address1,
+			&a.Address2,
+			&a.City,
+			&a.State,
+			&a.PostalCode,
+			&a.Country,
+			&a.Validated,
+		); err != nil {
+			return nil, fmt.Errorf("scanning row: %v", err)
+		}
+		ret[customerID] = append(ret[customerID], a)
+	}
+
+	return ret, nil
+}
+
+func (r *sqlCustomerRepository) getMetadata(customerIDs []string) (map[string]client.CustomerMetadata, error) {
+	query := fmt.Sprintf(
+		"select customer_id, meta_key, meta_value from customer_metadata where customer_id in (?%s);",
+		strings.Repeat(",?", len(customerIDs)-1),
+	)
+	rows, err := r.queryRowsByCustomerIDs(query, customerIDs)
+	if err != nil {
+		return nil, err
+	}
+	defer rows.Close()
+
+	ret := make(map[string]client.CustomerMetadata)
+	for rows.Next() {
+		m := client.CustomerMetadata{
+			Metadata: make(map[string]string),
+		}
+		var customerID string
+		var k, v string
+		if err := rows.Scan(&customerID, &k, &v); err != nil {
+			return nil, fmt.Errorf("scanning row: %v", err)
+		}
+		m.Metadata[k] = v
+		ret[customerID] = m
+	}
+	return ret, nil
+}
+
+func (r *sqlCustomerRepository) queryRowsByCustomerIDs(query string, customerIDs []string) (*sql.Rows, error) {
+	stmt, err := r.db.Prepare(query)
+	if err != nil {
+		return nil, fmt.Errorf("preparing query: %v", err)
+	}
+	defer stmt.Close()
+
+	var args []interface{}
+	for _, id := range customerIDs {
+		args = append(args, id)
+	}
+
+	rows, err := stmt.Query(args...)
+	if err != nil {
+		return nil, fmt.Errorf("executing query: %v", err)
+	}
+
+	return rows, nil
+}
diff --git a/pkg/customers/customers.go b/pkg/customers/customers.go
index 1775456..02df5e9 100644
--- a/pkg/customers/customers.go
+++ b/pkg/customers/customers.go
@@ -400,7 +400,6 @@ type CustomerRepository interface {
 
 	searchCustomers(params SearchParams) ([]*client.Customer, error)
 
-	getCustomerMetadata(customerID string) (map[string]string, error)
 	replaceCustomerMetadata(customerID string, metadata map[string]string) error
 
 	addCustomerAddress(customerID string, address address) error
@@ -610,99 +609,19 @@ func (r *sqlCustomerRepository) updateAddressesByCustomerID(tx *sql.Tx, customer
 }
 
 func (r *sqlCustomerRepository) GetCustomer(customerID string) (*client.Customer, error) {
-	query := `select first_name, middle_name, last_name, nick_name, suffix, type, birth_date, status, email, created_at, last_modified from customers where customer_id = ? and deleted_at is null limit 1;`
-	stmt, err := r.db.Prepare(query)
-	if err != nil {
-		return nil, err
-	}
-
-	row := stmt.QueryRow(customerID)
-
-	var birthDate *string
-	var cust client.Customer
-	cust.CustomerID = customerID
-	err = row.Scan(&cust.FirstName, &cust.MiddleName, &cust.LastName, &cust.NickName, &cust.Suffix, &cust.Type, &birthDate, &cust.Status, &cust.Email, &cust.CreatedAt,
-		&cust.LastModified)
-	stmt.Close()
-	if err != nil && !strings.Contains(err.Error(), "no rows in result set") {
-		return nil, fmt.Errorf("GetCustomer: %v", err)
-	}
-	if cust.FirstName == "" {
-		return nil, nil // not found
-	}
-	if birthDate != nil {
-		cust.BirthDate = *birthDate
-	}
-
-	phones, err := r.readPhones(customerID)
+	custs, err := r.searchCustomers(SearchParams{
+		Count:       1,
+		CustomerIDs: []string{customerID},
+	})
 	if err != nil {
-		return nil, fmt.Errorf("GetCustomer: readPhones: %v", err)
+		return nil, fmt.Errorf("getting customer: %v", err)
 	}
-	cust.Phones = phones
 
-	addresses, err := r.readAddresses(customerID)
-	if err != nil {
-		return nil, fmt.Errorf("GetCustomer: readAddresses: %v", err)
+	if len(custs) == 0 {
+		return nil, errors.New("customer not found")
 	}
-	cust.Addresses = addresses
 
-	metadata, err := r.getCustomerMetadata(customerID)
-	if err != nil {
-		return nil, fmt.Errorf("GetCustomer: getCustomerMetadata: %v", err)
-	}
-	cust.Metadata = metadata
-
-	return &cust, nil
-}
-
-func (r *sqlCustomerRepository) readPhones(customerID string) ([]client.Phone, error) {
-	query := `select number, valid, type from customers_phones where customer_id = ?;`
-	stmt, err := r.db.Prepare(query)
-	if err != nil {
-		return nil, fmt.Errorf("GetCustomer: prepare customers_phones: err=%v", err)
-	}
-	defer stmt.Close()
-
-	rows, err := stmt.Query(customerID)
-	if err != nil {
-		return nil, fmt.Errorf("GetCustomer: query customers_phones: err=%v", err)
-	}
-	defer rows.Close()
-
-	var phones []client.Phone
-	for rows.Next() {
-		var p client.Phone
-		if err := rows.Scan(&p.Number, &p.Valid, &p.Type); err != nil {
-			return nil, fmt.Errorf("GetCustomer: scan customers_phones: err=%v", err)
-		}
-		phones = append(phones, p)
-	}
-	return phones, rows.Err()
-}
-
-func (r *sqlCustomerRepository) readAddresses(customerID string) ([]client.CustomerAddress, error) {
-	query := `select address_id, type, address1, address2, city, state, postal_code, country, validated from customers_addresses where customer_id = ? and deleted_at is null;`
-	stmt, err := r.db.Prepare(query)
-	if err != nil {
-		return nil, fmt.Errorf("readAddresses: prepare customers_addresses: err=%v", err)
-	}
-	defer stmt.Close()
-
-	rows, err := stmt.Query(customerID)
-	if err != nil {
-		return nil, fmt.Errorf("readAddresses: query customers_addresses: err=%v", err)
-	}
-	defer rows.Close()
-
-	var adds []client.CustomerAddress
-	for rows.Next() {
-		var a client.CustomerAddress
-		if err := rows.Scan(&a.AddressID, &a.Type, &a.Address1, &a.Address2, &a.City, &a.State, &a.PostalCode, &a.Country, &a.Validated); err != nil {
-			return nil, fmt.Errorf("readAddresses: scan customers_addresses: err=%v", err)
-		}
-		adds = append(adds, a)
-	}
-	return adds, rows.Err()
+	return custs[0], nil
 }
 
 func (r *sqlCustomerRepository) updateCustomerStatus(customerID string, status client.CustomerStatus, comment string) error {
@@ -736,32 +655,6 @@ func (r *sqlCustomerRepository) updateCustomerStatus(customerID string, status c
 	return tx.Commit()
 }
 
-func (r *sqlCustomerRepository) getCustomerMetadata(customerID string) (map[string]string, error) {
-	out := make(map[string]string)
-
-	query := `select meta_key, meta_value from customer_metadata where customer_id = ?;`
-	stmt, err := r.db.Prepare(query)
-	if err != nil {
-		return out, fmt.Errorf("getCustomerMetadata: prepare: %v", err)
-	}
-	defer stmt.Close()
-
-	rows, err := stmt.Query(customerID)
-	if err != nil {
-		return out, fmt.Errorf("getCustomerMetadata: query: %v", err)
-	}
-	defer rows.Close()
-
-	for rows.Next() {
-		key, value := "", ""
-		if err := rows.Scan(&key, &value); err != nil {
-			return out, fmt.Errorf("getCustomerMetadata: scan: %v", err)
-		}
-		out[key] = value
-	}
-	return out, nil
-}
-
 func (r *sqlCustomerRepository) replaceCustomerMetadata(customerID string, metadata map[string]string) error {
 	tx, err := r.db.Begin()
 	if err != nil {


Test patch:
diff --git a/pkg/customers/customer_search_test.go b/pkg/customers/customer_search_test.go
index 9433d7c..548a73c 100644
--- a/pkg/customers/customer_search_test.go
+++ b/pkg/customers/customer_search_test.go
@@ -7,20 +7,24 @@ package customers
 import (
 	"database/sql"
 	"encoding/json"
+	"fmt"
 	"net/http"
 	"net/http/httptest"
+	"strings"
 	"testing"
 
 	"github.com/gorilla/mux"
 	"github.com/moov-io/base/log"
+	"github.com/stretchr/testify/require"
 
 	"github.com/moov-io/customers/internal/database"
 	"github.com/moov-io/customers/pkg/client"
 )
 
 func TestCustomersSearchRouter(t *testing.T) {
-	repo := createTestCustomerRepository(t)
-	defer repo.close()
+	db := database.CreateTestMySQLDB(t)
+	repo := NewCustomerRepo(log.NewNopLogger(), db.DB)
+	defer db.Close()
 
 	router := mux.NewRouter()
 	AddCustomerRoutes(log.NewNopLogger(), router, repo, nil, nil)
@@ -77,82 +81,114 @@ func TestCustomersSearchRouter(t *testing.T) {
 	}
 }
 
-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
+func TestRepository__searchCustomers(t *testing.T) {
+	logger := log.NewNopLogger()
+	org := "test"
+	createCustomer := func(repo CustomerRepository, i int) *client.Customer {
+		var req = &customerRequest{
+			FirstName: fmt.Sprintf("jane-%d", i),
+			LastName:  fmt.Sprintf("doe-%d", i),
+			Email:     fmt.Sprintf("jane-%d@moov.com", i),
+			Phones: []phone{
+				{
+					Number: "555-555-5555",
+					Type:   "primary",
+				},
+			},
+			Addresses: []address{
+				{
+					Type:       "primary",
+					Address1:   "123 Cool St.",
+					City:       "San Francisco",
+					State:      "CA",
+					PostalCode: "94030",
+					Country:    "US",
+				},
+			},
+			Metadata: map[string]string{"key": "val"},
 		}
-		return stmt.Close()
+		cust, _, _ := req.asCustomer(testCustomerSSNStorage(t))
+		require.NoError(t, repo.CreateCustomer(cust, org))
+		return cust
 	}
 
-	// Query search
-	query, args := buildSearchQuery(
-		SearchParams{
-			Organization: "foo",
-			Query:        "jane doe",
-			Count:        100,
+	tests := []struct {
+		desc string
+		db   *sql.DB
+	}{
+		{
+			desc: "sqlite",
+			db:   database.CreateTestSqliteDB(t).DB,
 		},
-	)
-	if query != "select customer_id from customers where deleted_at is null and organization = ? and lower(first_name) || \" \" || lower(last_name) LIKE ? order by created_at desc 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)
-	}
-
-	// Eamil search
-	query, args = buildSearchQuery(
-		SearchParams{
-			Organization: "foo",
-			Email:        "jane.doe@moov.io",
+		{
+			desc: "mysql",
+			db:   database.CreateTestMySQLDB(t).DB,
 		},
-	)
-	if query != "select customer_id from customers where deleted_at is null and organization = ? and lower(email) like ? order by created_at desc 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)
 	}
 
-	// Query and Eamil saerch
-	query, args = buildSearchQuery(
-		SearchParams{
-			Organization: "foo",
-			Query:        "jane doe",
-			Email:        "jane.doe@moov.io",
-			Count:        25,
-		},
-	)
-	if query != "select customer_id from customers where deleted_at is null and organization = ? and lower(first_name) || \" \" || lower(last_name) LIKE ? and lower(email) like ? order by created_at desc 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) != 4 {
-		t.Errorf("unexpected args: %#v", args)
+	for _, tc := range tests {
+		t.Run(tc.desc, func(t *testing.T) {
+			defer tc.db.Close()
+			repo := NewCustomerRepo(logger, tc.db)
+
+			n := 20 // seed database with customers
+			var customers []*client.Customer
+			for i := 0; i < n; i++ {
+				customers = append(customers, createCustomer(repo, i))
+			}
+
+			/* Search by Count */
+			params := SearchParams{
+				Organization: org,
+				Count:        5,
+			}
+			got, err := repo.searchCustomers(params)
+			require.NoError(t, err)
+			require.Len(t, got, int(params.Count))
+
+			/* Search by email */
+			params = SearchParams{
+				Organization: org,
+				Count:        1,
+				Email:        "JaNe-3@moov.com",
+			}
+			got, err = repo.searchCustomers(params)
+			require.NoError(t, err)
+			require.Equal(t, strings.ToLower(params.Email), got[0].Email)
+
+			if tc.desc == "mysql" {
+				/* Search by query */
+				params = SearchParams{
+					Organization: org,
+					Count:        100,
+					Query:        "jane-10",
+				}
+				got, err = repo.searchCustomers(params)
+				require.NoError(t, err)
+				require.Len(t, got, 1)
+			}
+
+			/* Search by customerIDs */
+			wantIDs := make([]string, 3)
+			for i := 0; i < len(wantIDs); i++ {
+				wantIDs[i] = customers[i].CustomerID
+			}
+
+			got, err = repo.searchCustomers(SearchParams{
+				Organization: org,
+				CustomerIDs:  wantIDs,
+				Count:        10,
+			})
+			require.NoError(t, err)
+			require.Len(t, got, len(wantIDs))
+
+			var gotIDs []string
+			for _, c := range got {
+				gotIDs = append(gotIDs, c.CustomerID)
+			}
+
+			require.ElementsMatch(t, wantIDs, gotIDs)
+		})
 	}
 }
 
@@ -192,7 +228,7 @@ func TestGet100MostRecentlyCreatedCustomersWhenSpecifyingMoreThanAvailable(t *te
 	scope.assert.Equal(100, len(customers))
 }
 
-func TestGetCustomersWithVerifiedStatus(t *testing.T) {
+func TestSearchCustomersWithVerifiedStatus(t *testing.T) {
 	// Create two customers. 1 with Unknown STATUS and 1 with Verified
 	scope := Setup(t)
 	customer := scope.CreateCustomer("John", "Doe", "john.doe@email.com", client.INDIVIDUAL)
@@ -216,7 +252,7 @@ func TestGetCustomersWithVerifiedStatus(t *testing.T) {
 	}
 }
 
-func TestGetCustomersByName(t *testing.T) {
+func TestSearchCustomersByName(t *testing.T) {
 	scope := Setup(t)
 	_ = scope.CreateCustomer("Jane", "Doe", "jane.doe@gmail.com", client.INDIVIDUAL)
 	_ = scope.CreateCustomer("John", "Doe", "jane.doe@gmail.com", client.INDIVIDUAL)
@@ -240,7 +276,7 @@ func TestGetCustomersByName(t *testing.T) {
 	scope.assert.Equal(0, len(customers))
 }
 
-func TestGetCustomersByEmail(t *testing.T) {
+func TestSearchCustomersByEmail(t *testing.T) {
 	scope := Setup(t)
 	_ = scope.CreateCustomer("Jane", "Doe", "jane.doe@gmail.com", client.INDIVIDUAL)
 	_ = scope.CreateCustomer("John", "Doe", "john.doe@gmail.com", client.INDIVIDUAL)
@@ -255,7 +291,7 @@ func TestGetCustomersByEmail(t *testing.T) {
 	scope.assert.Equal(0, len(customers))
 }
 
-func TestGetCustomersByNameAndEmail(t *testing.T) {
+func TestSearchCustomersByNameAndEmail(t *testing.T) {
 	scope := Setup(t)
 	_ = scope.CreateCustomer("Jane", "Doe", "jane.doe@gmail.com", client.INDIVIDUAL)
 	_ = scope.CreateCustomer("John", "Doe", "john.doe@gmail.com", client.INDIVIDUAL)
@@ -282,7 +318,7 @@ func TestGetCustomersByNameAndEmail(t *testing.T) {
 	scope.assert.Equal(0, len(customers))
 }
 
-func TestGetCustomersUsingPaging(t *testing.T) {
+func TestSearchCustomersUsingPaging(t *testing.T) {
 	scope := Setup(t)
 	_ = scope.CreateCustomers(30, client.INDIVIDUAL)
 
@@ -303,7 +339,7 @@ func TestGetCustomersUsingPaging(t *testing.T) {
 	scope.assert.Equal(0, len(customers))
 }
 
-func TestGetCustomersUsingPagingFailure(t *testing.T) {
+func TestSearchCustomersUsingPagingFailure(t *testing.T) {
 	scope := Setup(t)
 	_ = scope.CreateCustomers(30, client.INDIVIDUAL)
 
@@ -317,7 +353,7 @@ func TestGetCustomersUsingPagingFailure(t *testing.T) {
 	scope.assert.Equal(0, len(customers))
 }
 
-func TestGetCustomersByType(t *testing.T) {
+func TestSearchCustomersByType(t *testing.T) {
 	scope := Setup(t)
 	_ = scope.CreateCustomers(10, client.INDIVIDUAL)
 	_ = scope.CreateCustomers(20, client.BUSINESS)
diff --git a/pkg/customers/customers_test.go b/pkg/customers/customers_test.go
index 9ac7c9a..6d5a009 100644
--- a/pkg/customers/customers_test.go
+++ b/pkg/customers/customers_test.go
@@ -76,11 +76,6 @@ func (r *testCustomerRepository) searchCustomers(params SearchParams) ([]*client
 	return nil, nil
 }
 
-func (r *testCustomerRepository) getCustomerMetadata(customerID string) (map[string]string, error) {
-	out := make(map[string]string)
-	return out, r.err
-}
-
 func (r *testCustomerRepository) replaceCustomerMetadata(customerID string, metadata map[string]string) error {
 	return r.err
 }
@@ -221,7 +216,7 @@ func TestCustomers__DeleteCustomer(t *testing.T) {
 	require.Equal(t, http.StatusNoContent, w.Code)
 	require.Empty(t, w.Body)
 	got, err = repo.GetCustomer(customer.CustomerID)
-	require.NoError(t, err)
+	require.Error(t, err)
 	require.Nil(t, got)
 }
 
@@ -256,7 +251,7 @@ func TestCustomerRepository__CreateCustomer(t *testing.T) {
 	check(t, &sqlCustomerRepository{mysqlDB.DB, log.NewNopLogger()})
 }
 
-func TestCustomers__GetCustomersError(t *testing.T) {
+func TestCustomers__searchCustomersError(t *testing.T) {
 	repo := &testCustomerRepository{err: errors.New("bad error")}
 
 	w := httptest.NewRecorder()
@@ -498,8 +493,8 @@ func TestCustomers__repository(t *testing.T) {
 	defer repo.close()
 
 	cust, err := repo.GetCustomer(base.ID())
-	if err != nil {
-		t.Errorf("unexpected error: %v", err)
+	if err == nil {
+		t.Errorf("expected not found error: %v", err)
 	}
 	if cust != nil {
 		t.Error("expected no Customer")
@@ -858,12 +853,16 @@ func TestCustomerRepository__metadata(t *testing.T) {
 	repo := createTestCustomerRepository(t)
 	defer repo.close()
 
+	getMetadata := func(customerID string) map[string]string {
+		meta, err := repo.getMetadata([]string{customerID})
+		if err != nil {
+			t.Fatal(err)
+		}
+		return meta[customerID].Metadata
+	}
 	customerID := base.ID()
 
-	meta, err := repo.getCustomerMetadata(customerID)
-	if err != nil {
-		t.Fatal(err)
-	}
+	meta := getMetadata(customerID)
 	if len(meta) != 0 {
 		t.Errorf("unknown metadata: %#v", meta)
 	}
@@ -872,10 +871,8 @@ func TestCustomerRepository__metadata(t *testing.T) {
 	if err := repo.replaceCustomerMetadata(customerID, map[string]string{"key": "bar"}); err != nil {
 		t.Fatal(err)
 	}
-	meta, err = repo.getCustomerMetadata(customerID)
-	if err != nil {
-		t.Fatal(err)
-	}
+
+	meta = getMetadata(customerID)
 	if len(meta) != 1 || meta["key"] != "bar" {
 		t.Errorf("unknown metadata: %#v", meta)
 	}