From bad8cf30094224fa38a5928e2af2c100a2cea73b Mon Sep 17 00:00:00 2001 From: Drew Bednar Date: Sun, 23 Feb 2025 17:50:17 -0500 Subject: [PATCH] Testing with mocks --- cmd/ratchetd/main.go | 3 +- cmd/ratchetd/main_test.go | 55 ++++++++++++++++++++++++++++++++++ cmd/ratchetd/testutils_test.go | 16 +++++++++- internal/assert/assert.go | 14 ++++++++- internal/model/snippets.go | 6 ++++ internal/model/user.go | 6 ++++ internal/server/handlers.go | 10 +++---- internal/server/middleware.go | 2 +- internal/server/server.go | 11 ++++--- internal/server/templates.go | 1 - 10 files changed, 108 insertions(+), 16 deletions(-) diff --git a/cmd/ratchetd/main.go b/cmd/ratchetd/main.go index d928daf..7601fde 100644 --- a/cmd/ratchetd/main.go +++ b/cmd/ratchetd/main.go @@ -16,6 +16,7 @@ import ( rdb "git.runcible.io/learning/ratchet/internal/database" "git.runcible.io/learning/ratchet/internal/logging" + "git.runcible.io/learning/ratchet/internal/model" "git.runcible.io/learning/ratchet/internal/server" "github.com/alexedwards/scs/sqlite3store" "github.com/alexedwards/scs/v2" @@ -76,7 +77,7 @@ func run(ctx context.Context, w io.Writer, args []string) error { // for a GET request, so the user won't be treated as being "logged in" to the app even if they // did in another tab // sm.Cookie.SameSite = http.SameSiteStrictMode - app := server.NewRatchetApp(logger, tc, db, sm) + app := server.NewRatchetApp(logger, tc, &model.SnippetService{DB: db}, &model.UserService{DB: db}, sm) // these two elliptic curves have assembly implementations tlsConfig := tls.Config{ diff --git a/cmd/ratchetd/main_test.go b/cmd/ratchetd/main_test.go index d8907a3..6d0508f 100644 --- a/cmd/ratchetd/main_test.go +++ b/cmd/ratchetd/main_test.go @@ -56,3 +56,58 @@ func TestPingIntegration(t *testing.T) { assert.Equal(t, code, http.StatusOK) assert.Equal(t, body, "OK") } + +func TestSnippetView(t *testing.T) { + t.Parallel() + app := newTestApplication(t) + + ts := newTestServer(t, app.Routes()) + defer ts.Close() + + tests := []struct { + name string + urlPath string + wantCode int + wantBody string + }{ + { + name: "Valid ID", + urlPath: "/snippet/view/1", + wantCode: 200, + wantBody: "Hello golang mocking", + }, + { + name: "Nonexistent ID", + urlPath: "/snippet/view/2", + wantCode: 404, + }, + { + name: "Negative ID", + urlPath: "/snippet/view/-1", + wantCode: 404, + }, + { + name: "Decimal ID", + urlPath: "/snippet/view/1.23", + wantCode: 404, + }, + { + name: "string ID", + urlPath: "/snippet/view/foo", + wantCode: 404, + }, + { + name: "emptry ID", + urlPath: "/snippet/view/", + wantCode: 404, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + code, _, body := ts.get(t, test.urlPath) + assert.Equal(t, code, test.wantCode) + assert.StringContains(t, body, test.wantBody) + }) + } +} diff --git a/cmd/ratchetd/testutils_test.go b/cmd/ratchetd/testutils_test.go index 1c53e32..940b210 100644 --- a/cmd/ratchetd/testutils_test.go +++ b/cmd/ratchetd/testutils_test.go @@ -7,14 +7,28 @@ import ( "net/http/cookiejar" "net/http/httptest" "testing" + "time" + "git.runcible.io/learning/ratchet/internal/model/mock" "git.runcible.io/learning/ratchet/internal/server" + "github.com/alexedwards/scs/v2" ) // Create a newTestApplication helper which returns an instance of our // application struct containing mocked dependencies. func newTestApplication(t *testing.T) *server.RatchetApp { - rs := server.NewRatchetApp(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, nil, nil) + + //tc, err := server.InitTemplateCache() + tc, err := server.InitFSTemplateCache() + if err != nil { + t.Fatal(err) + } + + sessionManager := scs.New() + sessionManager.Lifetime = 12 * time.Hour + sessionManager.Cookie.Secure = true + + rs := server.NewRatchetApp(slog.New(slog.NewTextHandler(io.Discard, nil)), tc, &mock.SnippetService{}, &mock.UserService{}, sessionManager) return rs } diff --git a/internal/assert/assert.go b/internal/assert/assert.go index 1417379..d9a7019 100644 --- a/internal/assert/assert.go +++ b/internal/assert/assert.go @@ -1,6 +1,9 @@ package assert -import "testing" +import ( + "strings" + "testing" +) // Equal a generic function to test equivalence between two values // of the same type @@ -16,3 +19,12 @@ func Equal[T comparable](t *testing.T, actual, expected T) { t.Errorf("got: %v; want %v", actual, expected) } } + +func StringContains(t *testing.T, actual, expectedSubstring string) { + + t.Helper() + + if !strings.Contains(actual, expectedSubstring) { + t.Errorf("got: %q; expected to contain %q", actual, expectedSubstring) + } +} diff --git a/internal/model/snippets.go b/internal/model/snippets.go index 07df2d2..9333044 100644 --- a/internal/model/snippets.go +++ b/internal/model/snippets.go @@ -23,6 +23,12 @@ func (s *Snippet) GetTitle() { return } +type SnippetServiceInterface interface { + Insert(title, content string, expiresAt int) (int, error) + Get(id int) (Snippet, error) + Lastest() ([]Snippet, error) +} + type SnippetService struct { DB *sql.DB } diff --git a/internal/model/user.go b/internal/model/user.go index 821fb29..95be99d 100644 --- a/internal/model/user.go +++ b/internal/model/user.go @@ -20,6 +20,12 @@ type User struct { UpdatedAt time.Time } +type UserServiceInterface interface { + Insert(name, email, password string) (int, error) + Authenticate(email, password string) (int, error) + Exists(id int) (bool, error) +} + // TODD add logger to service type UserService struct { DB *sql.DB diff --git a/internal/server/handlers.go b/internal/server/handlers.go index 1bbd8e0..8d36b8b 100644 --- a/internal/server/handlers.go +++ b/internal/server/handlers.go @@ -15,7 +15,7 @@ import ( ) // TODO function should accept and a pointer to an interface allowing for mocking in tests. -func handleHome(logger *slog.Logger, tc *TemplateCache, sm *scs.SessionManager, snippetService *model.SnippetService) http.Handler { +func handleHome(logger *slog.Logger, tc *TemplateCache, sm *scs.SessionManager, snippetService model.SnippetServiceInterface) http.Handler { return http.HandlerFunc( func(w http.ResponseWriter, r *http.Request) { // Retrieve Snippets from DB @@ -63,7 +63,7 @@ func handleHome(logger *slog.Logger, tc *TemplateCache, sm *scs.SessionManager, }) } -func handleSnippetView(logger *slog.Logger, tc *TemplateCache, sm *scs.SessionManager, snippetService *model.SnippetService) http.Handler { +func handleSnippetView(logger *slog.Logger, tc *TemplateCache, sm *scs.SessionManager, snippetService model.SnippetServiceInterface) http.Handler { return http.HandlerFunc( func(w http.ResponseWriter, r *http.Request) { id, err := strconv.Atoi(r.PathValue("id")) @@ -151,7 +151,7 @@ func handleSnippetCreateGet(tc *TemplateCache, sm *scs.SessionManager) http.Hand // snippetCreate handles display of the form used to create snippets // // curl -iL -d "" http://localhost:5001/snippet/create -func handleSnippetCreatePost(logger *slog.Logger, tc *TemplateCache, formDecoder *form.Decoder, sm *scs.SessionManager, snippetService *model.SnippetService) http.Handler { +func handleSnippetCreatePost(logger *slog.Logger, tc *TemplateCache, formDecoder *form.Decoder, sm *scs.SessionManager, snippetService model.SnippetServiceInterface) http.Handler { return http.HandlerFunc( func(w http.ResponseWriter, r *http.Request) { // example of a custom header. Must be done before calling WriteHeader @@ -291,7 +291,7 @@ func handleUserSignupGet(tc *TemplateCache, sm *scs.SessionManager) http.Handler }) } -func handleUserSignupPost(logger *slog.Logger, tc *TemplateCache, fd *form.Decoder, sm *scs.SessionManager, userService *model.UserService) http.Handler { +func handleUserSignupPost(logger *slog.Logger, tc *TemplateCache, fd *form.Decoder, sm *scs.SessionManager, userService model.UserServiceInterface) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Check that the provided name, email address and password are not blank. @@ -355,7 +355,7 @@ func handleUserLoginGet(tc *TemplateCache, sm *scs.SessionManager) http.Handler }) } -func handleUserLoginPost(logger *slog.Logger, tc *TemplateCache, sm *scs.SessionManager, fd *form.Decoder, userService *model.UserService) http.Handler { +func handleUserLoginPost(logger *slog.Logger, tc *TemplateCache, sm *scs.SessionManager, fd *form.Decoder, userService model.UserServiceInterface) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // parse form err := r.ParseForm() diff --git a/internal/server/middleware.go b/internal/server/middleware.go index 51f7c3c..019a543 100644 --- a/internal/server/middleware.go +++ b/internal/server/middleware.go @@ -139,7 +139,7 @@ func NoSurfMiddleware(next http.Handler) http.Handler { return csrfHandler } -func AuthenticateMiddleware(next http.Handler, sm *scs.SessionManager, userService *model.UserService) http.Handler { +func AuthenticateMiddleware(next http.Handler, sm *scs.SessionManager, userService model.UserServiceInterface) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { id := sm.GetInt(r.Context(), "authenticatedUserID") diff --git a/internal/server/server.go b/internal/server/server.go index 2e5cb2f..064b7db 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -1,7 +1,6 @@ package server import ( - "database/sql" "log/slog" "git.runcible.io/learning/ratchet/internal/model" @@ -13,19 +12,19 @@ type RatchetApp struct { logger *slog.Logger templateCache *TemplateCache //Services used by HTTP routes - snippetService *model.SnippetService - userService *model.UserService + snippetService model.SnippetServiceInterface + userService model.UserServiceInterface formDecoder *form.Decoder sessionManager *scs.SessionManager } // TODO this function presents some challenges because it both instantiates new data objects // and configures route / middleware setup -func NewRatchetApp(logger *slog.Logger, tc *TemplateCache, db *sql.DB, sm *scs.SessionManager) *RatchetApp { +func NewRatchetApp(logger *slog.Logger, tc *TemplateCache, snippetService model.SnippetServiceInterface, userService model.UserServiceInterface, sm *scs.SessionManager) *RatchetApp { rs := new(RatchetApp) rs.logger = logger - rs.snippetService = &model.SnippetService{DB: db} - rs.userService = &model.UserService{DB: db} + rs.snippetService = snippetService + rs.userService = userService rs.formDecoder = form.NewDecoder() rs.templateCache = tc rs.sessionManager = sm diff --git a/internal/server/templates.go b/internal/server/templates.go index fd57571..2c4c524 100644 --- a/internal/server/templates.go +++ b/internal/server/templates.go @@ -180,7 +180,6 @@ func renderTemplate(w http.ResponseWriter, r *http.Request, tc *TemplateCache, s serverError(w, r, err) return } - w.Header().Set("Content-Length", "this isn't an integer!") w.WriteHeader(status) buf.WriteTo(w)