Repository: google/exposure-notifications-server
Pull request #1473 (2021-03-19 15:21:55)

Add tests for admin mirrors
**Release Note**

<!-- Enter your extended release note in the below block. If the PR requires
additional action from users switching to the new release, include the string
"action required". If no release note is required, write "NONE". -->

```release-note
Add tests for admin mirrors
```


Patch:
diff --git a/internal/admin/handle_mirrors.go b/internal/admin/handle_mirrors.go
index 1fa893f..f38d279 100644
--- a/internal/admin/handle_mirrors.go
+++ b/internal/admin/handle_mirrors.go
@@ -57,14 +57,9 @@ func (s *Server) HandleMirrorsSave() func(c *gin.Context) {
 				ErrorPage(c, fmt.Sprintf("Failed to delete mirror: %v", err))
 				return
 			}
-
 			m.AddSuccess(fmt.Sprintf("Deleted mirror %d", mirror.ID))
-			c.Redirect(http.StatusSeeOther, "/")
 		case "save":
-			if err := form.PopulateMirror(mirror); err != nil {
-				ErrorPage(c, fmt.Sprintf("error processing mirror: %v", err))
-				return
-			}
+			form.PopulateMirror(mirror)
 
 			updateFn := db.AddMirror
 			if mirror.ID != 0 {
@@ -74,14 +69,14 @@ func (s *Server) HandleMirrorsSave() func(c *gin.Context) {
 				ErrorPage(c, fmt.Sprintf("Error writing mirror: %v", err))
 				return
 			}
-
 			m.AddSuccess(fmt.Sprintf("Updated mirror %d", mirror.ID))
-
-			m["mirror"] = mirror
-			c.HTML(http.StatusOK, "mirror", m)
 		default:
 			ErrorPage(c, "Invalid form action")
+			return
 		}
+
+		c.Redirect(http.StatusSeeOther, fmt.Sprintf("/mirrors/%d", mirror.ID))
+		c.Abort()
 	}
 }
 
@@ -132,15 +127,15 @@ type mirrorFormData struct {
 	FilenameRewrite    string `form:"filename-rewrite"`
 }
 
-func (f *mirrorFormData) PopulateMirror(m *model.Mirror) error {
+func (f *mirrorFormData) PopulateMirror(m *model.Mirror) {
 	m.IndexFile = f.IndexFile
 	m.ExportRoot = f.ExportRoot
 	m.CloudStorageBucket = f.CloudStorageBucket
 	m.FilenameRoot = f.FilenameRoot
+
 	if f.FilenameRewrite != "" {
 		m.FilenameRewrite = &f.FilenameRewrite
 	} else {
 		m.FilenameRewrite = nil
 	}
-	return nil
 }
diff --git a/internal/admin/template.go b/internal/admin/template.go
index 8e7f5b0..0ff2be0 100644
--- a/internal/admin/template.go
+++ b/internal/admin/template.go
@@ -55,6 +55,8 @@ func timestampFormatter(f string) func(i interface{}) (string, error) {
 // deref dereferences a pointer into its concrete type.
 func deref(i interface{}) (string, error) {
 	switch t := i.(type) {
+	case nil:
+		return "", nil
 	case *string:
 		if t == nil {
 			return "", nil
diff --git a/internal/admin/util.go b/internal/admin/util.go
index 88d1cff..0c820a0 100644
--- a/internal/admin/util.go
+++ b/internal/admin/util.go
@@ -20,10 +20,6 @@ func (t TemplateMap) AddTitle(title string) {
 	t["title"] = title
 }
 
-func (t TemplateMap) AddSubNav(name string) {
-	t["subnav"] = name
-}
-
 func (t TemplateMap) AddErrors(errors ...string) {
 	t["error"] = errors
 }


Test patch:
diff --git a/internal/admin/admin_test.go b/internal/admin/admin_test.go
index e4a9f07..ecca566 100644
--- a/internal/admin/admin_test.go
+++ b/internal/admin/admin_test.go
@@ -26,6 +26,7 @@ import (
 	"runtime"
 	"strings"
 	"testing"
+	"time"
 
 	"github.com/gin-gonic/gin"
 	"github.com/google/exposure-notifications-server/internal/project"
@@ -171,9 +172,79 @@ func intPtr(i int) *int {
 	return &i
 }
 
+func int8Ptr(i int8) *int8 {
+	if i == 0 {
+		return nil
+	}
+	return &i
+}
+
+func int16Ptr(i int16) *int16 {
+	if i == 0 {
+		return nil
+	}
+	return &i
+}
+
+func int32Ptr(i int32) *int32 {
+	if i == 0 {
+		return nil
+	}
+	return &i
+}
+
+func int64Ptr(i int64) *int64 {
+	if i == 0 {
+		return nil
+	}
+	return &i
+}
+
+func uintPtr(i uint) *uint {
+	if i == 0 {
+		return nil
+	}
+	return &i
+}
+
+func uint8Ptr(i uint8) *uint8 {
+	if i == 0 {
+		return nil
+	}
+	return &i
+}
+
+func uint16Ptr(i uint16) *uint16 {
+	if i == 0 {
+		return nil
+	}
+	return &i
+}
+
+func uint32Ptr(i uint32) *uint32 {
+	if i == 0 {
+		return nil
+	}
+	return &i
+}
+
+func uint64Ptr(i uint64) *uint64 {
+	if i == 0 {
+		return nil
+	}
+	return &i
+}
+
 func stringPtr(s string) *string {
 	if s == "" {
 		return nil
 	}
 	return &s
 }
+
+func timePtr(t time.Time) *time.Time {
+	if t.IsZero() {
+		return nil
+	}
+	return &t
+}
diff --git a/internal/admin/handle_mirrors_test.go b/internal/admin/handle_mirrors_test.go
index b89b9db..1b6817a 100644
--- a/internal/admin/handle_mirrors_test.go
+++ b/internal/admin/handle_mirrors_test.go
@@ -15,9 +15,17 @@
 package admin
 
 import (
+	"fmt"
+	"io"
+	"net/http"
+	"strings"
 	"testing"
 
+	"github.com/google/exposure-notifications-server/internal/mirror/database"
 	"github.com/google/exposure-notifications-server/internal/mirror/model"
+	"github.com/google/exposure-notifications-server/internal/project"
+	"github.com/google/go-cmp/cmp"
+	"github.com/google/go-cmp/cmp/cmpopts"
 )
 
 func TestRenderMirrors(t *testing.T) {
@@ -29,3 +37,362 @@ func TestRenderMirrors(t *testing.T) {
 
 	testRenderTemplate(t, "mirror", m)
 }
+
+func TestPopulateMirror(t *testing.T) {
+	t.Parallel()
+
+	cases := []struct {
+		name string
+		form *mirrorFormData
+		exp  *model.Mirror
+	}{
+		{
+			name: "default",
+			form: &mirrorFormData{
+				IndexFile:          "index",
+				ExportRoot:         "export",
+				CloudStorageBucket: "bucket",
+				FilenameRoot:       "root",
+				FilenameRewrite:    "rewrite",
+			},
+			exp: &model.Mirror{
+				IndexFile:          "index",
+				ExportRoot:         "export",
+				CloudStorageBucket: "bucket",
+				FilenameRoot:       "root",
+				FilenameRewrite:    stringPtr("rewrite"),
+			},
+		},
+		{
+			name: "no_rewrite",
+			form: &mirrorFormData{
+				IndexFile:          "index",
+				ExportRoot:         "export",
+				CloudStorageBucket: "bucket",
+				FilenameRoot:       "root",
+				FilenameRewrite:    "",
+			},
+			exp: &model.Mirror{
+				IndexFile:          "index",
+				ExportRoot:         "export",
+				CloudStorageBucket: "bucket",
+				FilenameRoot:       "root",
+				FilenameRewrite:    nil,
+			},
+		},
+	}
+
+	for _, tc := range cases {
+		tc := tc
+
+		t.Run(tc.name, func(t *testing.T) {
+			t.Parallel()
+
+			var m model.Mirror
+			tc.form.PopulateMirror(&m)
+
+			opts := cmp.Options{cmpopts.IgnoreUnexported(model.Mirror{})}
+			if diff := cmp.Diff(tc.exp, &m, opts); diff != "" {
+				t.Errorf("mismatch (-want, +got):\n%s", diff)
+			}
+		})
+	}
+}
+
+func TestHandleMirrorsShow(t *testing.T) {
+	t.Parallel()
+
+	ctx := project.TestContext(t)
+
+	env, s := newTestServer(t)
+	db := env.Database()
+
+	mirror := &model.Mirror{
+		IndexFile:          "index",
+		ExportRoot:         "export",
+		CloudStorageBucket: "bucket",
+		FilenameRoot:       "root",
+		FilenameRewrite:    stringPtr("rewrite"),
+	}
+	mirrorDB := database.New(db)
+	if err := mirrorDB.AddMirror(ctx, mirror); err != nil {
+		t.Fatalf("error adding mirror: %v", err)
+	}
+
+	cases := []struct {
+		name   string
+		id     string
+		status int
+		want   []string
+	}{
+		{
+			name:   "lookup_existing",
+			id:     fmt.Sprintf("%d", mirror.ID),
+			status: 200,
+			want:   []string{mirror.IndexFile, *mirror.FilenameRewrite},
+		},
+		{
+			name:   "show_new",
+			id:     "0",
+			status: 200,
+			want:   []string{"New Authorized Health Authority"},
+		},
+		{
+			name:   "bad_id",
+			id:     "banana",
+			status: 500,
+			want:   []string{"unable to parse `id` param"},
+		},
+		{
+			name:   "non_existing",
+			id:     "123",
+			status: 500,
+			want:   []string{"error loading authorized app"},
+		},
+	}
+
+	for _, tc := range cases {
+		tc := tc
+		t.Run(tc.name, func(t *testing.T) {
+			t.Parallel()
+
+			server := newHTTPServer(t, http.MethodGet, "/:id", s.HandleMirrorsShow())
+
+			req, err := http.NewRequestWithContext(ctx, http.MethodGet, fmt.Sprintf("%s/%s", server.URL, tc.id), nil)
+			if err != nil {
+				t.Fatal(err)
+			}
+			client := server.Client()
+
+			resp, err := client.Do(req)
+			if err != nil {
+				t.Fatalf("error making http call: %v", err)
+			}
+			defer resp.Body.Close()
+
+			if got, want := resp.StatusCode, tc.status; got != want {
+				b, err := io.ReadAll(resp.Body)
+				if err != nil {
+					t.Fatal(err)
+				}
+				t.Errorf("expected status %d to be %d; headers: %#v; body: %s", got, want, resp.Header, b)
+
+				for _, want := range tc.want {
+					if !strings.Contains(string(b), want) {
+						t.Errorf("expected\n\n%s\n\nto contain\n\n%s\n\n", b, want)
+					}
+				}
+			}
+		})
+	}
+}
+
+func TestHandleMirrorsSave(t *testing.T) {
+	t.Parallel()
+
+	ctx := project.TestContext(t)
+
+	env, s := newTestServer(t)
+	db := env.Database()
+
+	mirror := &model.Mirror{
+		IndexFile:          "index",
+		ExportRoot:         "export",
+		CloudStorageBucket: "bucket",
+		FilenameRoot:       "root",
+		FilenameRewrite:    stringPtr("rewrite"),
+	}
+	mirrorDB := database.New(db)
+	if err := mirrorDB.AddMirror(ctx, mirror); err != nil {
+		t.Fatalf("error adding mirror: %v", err)
+	}
+
+	mirrorToDelete := &model.Mirror{
+		IndexFile:          "indexD",
+		ExportRoot:         "exportD",
+		CloudStorageBucket: "bucketD",
+		FilenameRoot:       "rootD",
+		FilenameRewrite:    stringPtr("rewriteD"),
+	}
+	if err := mirrorDB.AddMirror(ctx, mirrorToDelete); err != nil {
+		t.Fatalf("error adding mirror: %v", err)
+	}
+
+	cases := []struct {
+		name   string
+		id     string
+		seed   *model.Mirror
+		form   *mirrorFormData
+		status int
+		want   []string
+	}{
+		// Create
+		{
+			name: "create_new",
+			id:   "0",
+			form: &mirrorFormData{
+				Action:             "save",
+				IndexFile:          "index1",
+				ExportRoot:         "export1",
+				CloudStorageBucket: "bucket1",
+				FilenameRoot:       "root1",
+				FilenameRewrite:    "rewrite1",
+			},
+			status: 303,
+		},
+		{
+			name: "update_unknown",
+			id:   "123",
+			form: &mirrorFormData{
+				Action:             "save",
+				IndexFile:          "index1",
+				ExportRoot:         "export1",
+				CloudStorageBucket: "bucket1",
+				FilenameRoot:       "root1",
+				FilenameRewrite:    "rewrite1",
+			},
+			status: 500,
+			want:   []string{"Error loading mirror"},
+		},
+		{
+			name: "update_existing",
+			id:   fmt.Sprintf("%d", mirror.ID),
+			form: &mirrorFormData{
+				Action:             "save",
+				IndexFile:          "index2",
+				ExportRoot:         "export2",
+				CloudStorageBucket: "bucket2",
+				FilenameRoot:       "root2",
+				FilenameRewrite:    "rewrite2",
+			},
+			status: 303,
+			want:   []string{"Updated mirror"},
+		},
+		{
+			name: "update_bad_id",
+			id:   "banana",
+			form: &mirrorFormData{
+				Action:             "save",
+				IndexFile:          "index2",
+				ExportRoot:         "export2",
+				CloudStorageBucket: "bucket2",
+				FilenameRoot:       "root2",
+				FilenameRewrite:    "rewrite2",
+			},
+			status: 500,
+			want:   []string{"unable to parse `id` param"},
+		},
+
+		// Delete
+		{
+			name: "delete_unknown",
+			id:   "123",
+			form: &mirrorFormData{
+				Action:             "delete",
+				IndexFile:          "index1",
+				ExportRoot:         "export1",
+				CloudStorageBucket: "bucket1",
+				FilenameRoot:       "root1",
+				FilenameRewrite:    "rewrite1",
+			},
+			status: 500,
+			want:   []string{"failed to get mirror 123"},
+		},
+		{
+			name: "delete_existing",
+			id:   fmt.Sprintf("%d", mirrorToDelete.ID),
+			form: &mirrorFormData{
+				Action:             "delete",
+				IndexFile:          "index1",
+				ExportRoot:         "export1",
+				CloudStorageBucket: "bucket1",
+				FilenameRoot:       "root1",
+				FilenameRewrite:    "rewrite1",
+			},
+			status: 303,
+			want:   []string{"Deleted mirror"},
+		},
+		{
+			name: "delete_bad_id",
+			id:   "banana",
+			form: &mirrorFormData{
+				Action:             "delete",
+				IndexFile:          "index2",
+				ExportRoot:         "export2",
+				CloudStorageBucket: "bucket2",
+				FilenameRoot:       "root2",
+				FilenameRewrite:    "rewrite2",
+			},
+			status: 500,
+			want:   []string{"unable to parse `id` param"},
+		},
+
+		// Unknown action
+		{
+			name: "delete_bad_id",
+			id:   "1",
+			form: &mirrorFormData{
+				Action:             "banana",
+				IndexFile:          "index2",
+				ExportRoot:         "export2",
+				CloudStorageBucket: "bucket2",
+				FilenameRoot:       "root2",
+				FilenameRewrite:    "rewrite2",
+			},
+			status: 500,
+			want:   []string{"Invalid form action"},
+		},
+	}
+
+	for _, tc := range cases {
+		tc := tc
+
+		t.Run(tc.name, func(t *testing.T) {
+			t.Parallel()
+
+			if tc.seed != nil {
+				if err := mirrorDB.AddMirror(ctx, tc.seed); err != nil {
+					t.Fatalf("failed to add mirror: %v", err)
+				}
+			}
+
+			server := newHTTPServer(t, http.MethodPost, "/:id", s.HandleMirrorsSave())
+
+			// URL values
+			form, err := serializeForm(tc.form)
+			if err != nil {
+				t.Fatalf("unable to serialize form: %v", err)
+			}
+
+			req, err := http.NewRequestWithContext(ctx, http.MethodPost, fmt.Sprintf("%s/%s", server.URL, tc.id), strings.NewReader(form.Encode()))
+			if err != nil {
+				t.Fatal(err)
+			}
+			req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
+			client := server.Client()
+			client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
+				return http.ErrUseLastResponse
+			}
+
+			resp, err := client.Do(req)
+			if err != nil {
+				t.Fatalf("error making http call: %v", err)
+			}
+			defer resp.Body.Close()
+
+			if got, want := resp.StatusCode, tc.status; got != want {
+				b, err := io.ReadAll(resp.Body)
+				if err != nil {
+					t.Fatal(err)
+				}
+				t.Errorf("expected status %d to be %d; headers: %#v; body: %s", got, want, resp.Header, b)
+
+				for _, want := range tc.want {
+					if !strings.Contains(string(b), want) {
+						t.Errorf("expected\n\n%s\n\nto contain\n\n%s\n\n", b, want)
+					}
+				}
+			}
+		})
+	}
+}
diff --git a/internal/admin/server_test.go b/internal/admin/server_test.go
new file mode 100644
index 0000000..3e59b6c
--- /dev/null
+++ b/internal/admin/server_test.go
@@ -0,0 +1,67 @@
+// Copyright 2021 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, softwar
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package admin
+
+import (
+	"net/http"
+	"net/http/httptest"
+	"testing"
+
+	"github.com/google/exposure-notifications-server/internal/project"
+)
+
+func TestServerRoutes(t *testing.T) {
+	t.Parallel()
+
+	ctx := project.TestContext(t)
+
+	_, s := newTestServer(t)
+	h := s.Routes(ctx)
+
+	cases := []struct {
+		name string
+		path string
+	}{
+		{"assets", "/assets/styles.css"},
+		{"index", "/"},
+		{"apps", "/app"},
+		{"health_authority", "/healthauthority/0"},
+		{"exports", "/exports/0"},
+		{"export_importers", "/export-importers/0"},
+		{"mirrors", "/mirrors/0"},
+		{"siginfo", "/siginfo/0"},
+		{"health", "/health"},
+	}
+
+	for _, tc := range cases {
+		tc := tc
+
+		t.Run(tc.name, func(t *testing.T) {
+			t.Parallel()
+
+			r, err := http.NewRequestWithContext(ctx, http.MethodGet, tc.path, nil)
+			if err != nil {
+				t.Fatal(err)
+			}
+			w := httptest.NewRecorder()
+			h.ServeHTTP(w, r)
+			w.Flush()
+
+			if got, want := w.Code, 200; got != want {
+				t.Errorf("expected status %d to be %d; headers: %#v; body: %s", got, want, w.Header(), w.Body.String())
+			}
+		})
+	}
+}
diff --git a/internal/admin/template_test.go b/internal/admin/template_test.go
new file mode 100644
index 0000000..e8cc8cd
--- /dev/null
+++ b/internal/admin/template_test.go
@@ -0,0 +1,240 @@
+// Copyright 2021 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, softwar
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package admin
+
+import (
+	"testing"
+	"time"
+)
+
+func TestTimestampFormatter(t *testing.T) {
+	t.Parallel()
+
+	formatter := timestampFormatter("2006-01-02")
+
+	cases := []struct {
+		name string
+		in   interface{}
+		exp  string
+		err  bool
+	}{
+		{
+			name: "nil",
+			in:   nil,
+			exp:  "",
+		},
+		{
+			name: "time_zero",
+			in:   time.Time{},
+			exp:  "",
+		},
+		{
+			name: "time_non_zero",
+			in:   time.Unix(1000000000, 0).UTC(),
+			exp:  "2001-09-09",
+		},
+		{
+			name: "time_ptr_nil",
+			in:   (*time.Time)(nil),
+			exp:  "",
+		},
+		{
+			name: "time_ptr_zero",
+			in:   &time.Time{},
+			exp:  "",
+		},
+		{
+			name: "time_ptr_non_zero",
+			in:   timePtr(time.Unix(1000000000, 0).UTC()),
+			exp:  "2001-09-09",
+		},
+		{
+			name: "string",
+			in:   "foo",
+			exp:  "foo",
+		},
+		{
+			name: "other",
+			in:   64,
+			err:  true,
+		},
+	}
+
+	for _, tc := range cases {
+		tc := tc
+
+		t.Run(tc.name, func(t *testing.T) {
+			t.Parallel()
+
+			got, err := formatter(tc.in)
+			if (err != nil) != tc.err {
+				t.Fatal(err)
+			}
+
+			if got, want := got, tc.exp; got != want {
+				t.Errorf("expected %q to be %q", got, want)
+			}
+		})
+	}
+}
+
+func TestDeref(t *testing.T) {
+	t.Parallel()
+
+	cases := []struct {
+		name string
+		in   interface{}
+		exp  string
+		err  bool
+	}{
+		{
+			name: "nil",
+			in:   nil,
+			exp:  "",
+		},
+		{
+			name: "unknown",
+			in:   &struct{}{},
+			err:  true,
+		},
+		{
+			name: "string_ptr",
+			in:   stringPtr("hi"),
+			exp:  "hi",
+		},
+		{
+			name: "string_ptr_nil",
+			in:   (*string)(nil),
+			exp:  "",
+		},
+		{
+			name: "int_ptr",
+			in:   intPtr(123),
+			exp:  "123",
+		},
+		{
+			name: "int_ptr_nil",
+			in:   (*int)(nil),
+			exp:  "0",
+		},
+		{
+			name: "int8_ptr",
+			in:   int8Ptr(123),
+			exp:  "123",
+		},
+		{
+			name: "int8_ptr_nil",
+			in:   (*int8)(nil),
+			exp:  "0",
+		},
+		{
+			name: "int16_ptr",
+			in:   int16Ptr(123),
+			exp:  "123",
+		},
+		{
+			name: "int16_ptr_nil",
+			in:   (*int16)(nil),
+			exp:  "0",
+		},
+		{
+			name: "int32_ptr",
+			in:   int32Ptr(123),
+			exp:  "123",
+		},
+		{
+			name: "int32_ptr_nil",
+			in:   (*int32)(nil),
+			exp:  "0",
+		},
+		{
+			name: "int64_ptr",
+			in:   int64Ptr(123),
+			exp:  "123",
+		},
+		{
+			name: "int64_ptr_nil",
+			in:   (*int64)(nil),
+			exp:  "0",
+		},
+		{
+			name: "uint_ptr",
+			in:   uintPtr(123),
+			exp:  "123",
+		},
+		{
+			name: "uint_ptr_nil",
+			in:   (*uint)(nil),
+			exp:  "0",
+		},
+		{
+			name: "uint8_ptr",
+			in:   uint8Ptr(123),
+			exp:  "123",
+		},
+		{
+			name: "uint8_ptr_nil",
+			in:   (*uint8)(nil),
+			exp:  "0",
+		},
+		{
+			name: "uint16_ptr",
+			in:   uint16Ptr(123),
+			exp:  "123",
+		},
+		{
+			name: "uint16_ptr_nil",
+			in:   (*uint16)(nil),
+			exp:  "0",
+		},
+		{
+			name: "uint32_ptr",
+			in:   uint32Ptr(123),
+			exp:  "123",
+		},
+		{
+			name: "uint32_ptr_nil",
+			in:   (*uint32)(nil),
+			exp:  "0",
+		},
+		{
+			name: "uint64_ptr",
+			in:   uint64Ptr(123),
+			exp:  "123",
+		},
+		{
+			name: "uint64_ptr_nil",
+			in:   (*uint64)(nil),
+			exp:  "0",
+		},
+	}
+
+	for _, tc := range cases {
+		tc := tc
+
+		t.Run(tc.name, func(t *testing.T) {
+			t.Parallel()
+
+			got, err := deref(tc.in)
+			if (err != nil) != tc.err {
+				t.Fatal(err)
+			}
+
+			if got, want := got, tc.exp; got != want {
+				t.Errorf("expected %q to be %q", got, want)
+			}
+		})
+	}
+}