From e8d81d467f721292046f71bfdd903a15cdacff3d Mon Sep 17 00:00:00 2001 From: Drew Bednar Date: Mon, 19 Jan 2026 16:25:05 -0500 Subject: [PATCH] Added pgxmock for testing with required interface refactor --- README.md | 14 ++- cmd/api/handlers.go | 3 +- cmd/api/handlers_test.go | 122 +++++++++++++++++-- cmd/api/main.go | 2 +- go.mod | 1 + go.sum | 2 + internal/data/models.go | 8 +- internal/data/movies.go | 25 ++-- internal/database/database.go | 24 ++++ migrations/000001_create_movies_table.up.sql | 2 +- 10 files changed, 174 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index 47715f9..cfe9aeb 100644 --- a/README.md +++ b/README.md @@ -2,10 +2,22 @@ ![build-status](https://drone.runcible.io/api/badges/learning/pulley/status.svg) - A Golang HTTP API +## Local Development + +This project uses air and a docker container for postgres to serve as the local development environment. + + + +```bash +source .local.profile +make start-local +air +``` + + ## Routes Uses [CleanURLs](https://en.wikipedia.org/wiki/Clean_URL) diff --git a/cmd/api/handlers.go b/cmd/api/handlers.go index e5ddad9..416eefe 100644 --- a/cmd/api/handlers.go +++ b/cmd/api/handlers.go @@ -114,7 +114,6 @@ func (app *application) getMovieHandler(w http.ResponseWriter, r *http.Request) err = app.writeJSON(w, http.StatusOK, envelope{"movie": movie}, nil) if err != nil { - //app.logger.Error(err.Error()) //http.Error(w, "The server encountered a problem and could not process your request", http.StatusInternalServerError) app.serverErrorResponse(w, r, err) } @@ -270,7 +269,7 @@ func (app *application) listMoviesHandler(w http.ResponseWriter, r *http.Request return } - movies, err := app.models.Movies.List(r.Context()) + movies, err := app.models.Movies.GetAll(r.Context(), input.Title, input.Genres, input.Filters) if err != nil { app.serverErrorResponse(w, r, err) return diff --git a/cmd/api/handlers_test.go b/cmd/api/handlers_test.go index 7a11338..6f82cd2 100644 --- a/cmd/api/handlers_test.go +++ b/cmd/api/handlers_test.go @@ -8,28 +8,43 @@ import ( "log/slog" "net/http" "net/http/httptest" + "os" "strings" "testing" + "time" "git.runcible.io/learning/pulley/internal/assert" "git.runcible.io/learning/pulley/internal/config" + "git.runcible.io/learning/pulley/internal/data" + "git.runcible.io/learning/pulley/internal/database" + "github.com/pashagolub/pgxmock/v4" ) -func newTestApplication() application { +func newTestApplication(pool database.PgxIface) application { cfg := config.ServiceConfig{Env: "test"} - return application{config: cfg, logger: slog.New(slog.NewTextHandler(io.Discard, nil))} + mockModels := data.NewModels(pool) + // Discards log output from tests + // logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug, AddSource: true})) + + return application{config: cfg, logger: logger, models: mockModels} } func TestHealthRoute(t *testing.T) { respRec := httptest.NewRecorder() + mockPool, err := pgxmock.NewPool() + if err != nil { + t.Fatal(err) + } + defer mockPool.Close() r, err := http.NewRequest(http.MethodGet, "/v1/healthcheck", nil) if err != nil { t.Fatal(err) } - app := newTestApplication() + app := newTestApplication(mockPool) app.routes().ServeHTTP(respRec, r) resp := respRec.Result() @@ -49,15 +64,40 @@ func TestHealthRoute(t *testing.T) { func TestCreateMovieHandler(t *testing.T) { respRec := httptest.NewRecorder() + mockPool, err := pgxmock.NewPool() + if err != nil { + t.Fatal(err) + } + defer mockPool.Close() - requestBody := `{"title": "Moana", "year": 2019, "runtime": "120 mins", "genres": ["family", "Samoan"]}` + movie := struct { + Title string `json:"title"` + Year int32 `json:"year"` + Runtime string `json:"runtime"` + Genres []string `json:"genres"` + }{ + Title: "Moana", + Year: 2019, + Runtime: "120 mins", + Genres: []string{"family", "Samoan"}, + } + + mockPool.ExpectQuery("INSERT INTO movies"). + // must use Runtime explicitly for args + WithArgs(movie.Title, movie.Year, data.Runtime(120), movie.Genres). + WillReturnRows( + pgxmock.NewRows([]string{"id", "created_at", "version"}). + AddRow(1, time.Now(), 1), // These values will be scanned into the struct + ) + + rquestBody, _ := json.Marshal(movie) - r, err := http.NewRequest(http.MethodPost, "/v1/movies", strings.NewReader(requestBody)) + r, err := http.NewRequest(http.MethodPost, "/v1/movies", bytes.NewBuffer(rquestBody)) if err != nil { t.Fatal(err) } - app := newTestApplication() + app := newTestApplication(mockPool) app.routes().ServeHTTP(respRec, r) resp := respRec.Result() @@ -72,10 +112,20 @@ func TestCreateMovieHandler(t *testing.T) { body = bytes.TrimSpace(body) assert.StringContains(t, string(body), "Moana") + err = mockPool.ExpectationsWereMet() + if err != nil { + t.Errorf("there were unfulfilled expectations: %s", err) + } } // Consider simply testing app.jsonReader -func TestCreateMovieError(t *testing.T) { +func TestCreateMovieValidatorError(t *testing.T) { + + mockPool, err := pgxmock.NewPool() + if err != nil { + t.Fatal(err) + } + defer mockPool.Close() tests := []struct { name string @@ -148,7 +198,7 @@ func TestCreateMovieError(t *testing.T) { t.Fatal(err) } - app := newTestApplication() + app := newTestApplication(mockPool) app.routes().ServeHTTP(respRec, r) @@ -166,8 +216,15 @@ func TestCreateMovieError(t *testing.T) { } -func TestGetAllMoviesHandler(t *testing.T) { +func TestGetMovieHandler(t *testing.T) { + + mockPool, err := pgxmock.NewPool() + if err != nil { + t.Fatal(err) + } + defer mockPool.Close() + // TODO since this isn't a table test anymore should be able to refactor it testTable := []struct { name string id string @@ -179,10 +236,53 @@ func TestGetAllMoviesHandler(t *testing.T) { id: "1337", wantCode: 200, }, + } + + mockPool.ExpectQuery("SELECT id, created_at, title, year, runtime, genres, version FROM movies"). + WithArgs(int64(1337)).WillReturnRows( + pgxmock.NewRows([]string{"id", "created_at", "title", "year", "runtime", "genres", "version"}). + AddRow(int64(1337), time.Now(), "a laura is born", 1990, 36, []string{"family", "wife"}, 1), // These values will be scanned into the struct + ) + + for _, test := range testTable { + t.Run(test.name, func(t *testing.T) { + respRec := httptest.NewRecorder() + + r, err := http.NewRequest(http.MethodGet, fmt.Sprintf("/v1/movies/%s", test.id), nil) + t.Logf("Path: %s, Method: %s", r.URL.Path, r.Method) + assert.NilError(t, err) + + app := newTestApplication(mockPool) + // want to test with httprouter since we use it to parse context + app.routes().ServeHTTP(respRec, r) + + resp := respRec.Result() + t.Logf("Code: %d", resp.StatusCode) + assert.Equal(t, resp.StatusCode, test.wantCode) + + }) + } +} + +func TestGetMovieHandlerErrors(t *testing.T) { + + mockPool, err := pgxmock.NewPool() + if err != nil { + t.Fatal(err) + } + defer mockPool.Close() + + testTable := []struct { + name string + id string + wantCode int + useID bool + }{ + // will redirect to /v1/movies/ { name: "No ID provided", id: "", - wantCode: 404, + wantCode: 301, }, { name: "Negative ID", @@ -199,7 +299,7 @@ func TestGetAllMoviesHandler(t *testing.T) { t.Logf("Path: %s, Method: %s", r.URL.Path, r.Method) assert.NilError(t, err) - app := newTestApplication() + app := newTestApplication(mockPool) // want to test with httprouter since we use it to parse context app.routes().ServeHTTP(respRec, r) diff --git a/cmd/api/main.go b/cmd/api/main.go index e68da6d..f1a1d42 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -33,7 +33,7 @@ const Version = "1.0.0" type application struct { config config.ServiceConfig logger *slog.Logger - models data.Models + models *data.Models } func run(ctx context.Context, w io.Writer, args []string) error { diff --git a/go.mod b/go.mod index e4ec207..249b8b6 100644 --- a/go.mod +++ b/go.mod @@ -18,6 +18,7 @@ require ( github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/pashagolub/pgxmock/v4 v4.9.0 // indirect go.uber.org/atomic v1.7.0 // indirect golang.org/x/crypto v0.37.0 // indirect golang.org/x/sync v0.13.0 // indirect diff --git a/go.sum b/go.sum index 68fdd21..33b4d97 100644 --- a/go.sum +++ b/go.sum @@ -28,6 +28,8 @@ github.com/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dv github.com/kelseyhightower/envconfig v1.4.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/pashagolub/pgxmock/v4 v4.9.0 h1:itlO8nrVRnzkdMBXLs8pWUyyB2PC3Gku0WGIj/gGl7I= +github.com/pashagolub/pgxmock/v4 v4.9.0/go.mod h1:9L57pC193h2aKRHVyiiE817avasIPZnPwPlw3JczWvM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= diff --git a/internal/data/models.go b/internal/data/models.go index ada0794..f647060 100644 --- a/internal/data/models.go +++ b/internal/data/models.go @@ -3,7 +3,7 @@ package data import ( "errors" - "github.com/jackc/pgx/v5/pgxpool" + "git.runcible.io/learning/pulley/internal/database" ) var ( @@ -16,8 +16,8 @@ type Models struct { Movies MovieModel } -func NewModels(pool *pgxpool.Pool) Models { - return Models{ - Movies: MovieModel{pool: pool}, +func NewModels(pool database.PgxIface) *Models { + return &Models{ + Movies: MovieModel{db: pool}, } } diff --git a/internal/data/movies.go b/internal/data/movies.go index c14a94d..36fd3b2 100644 --- a/internal/data/movies.go +++ b/internal/data/movies.go @@ -5,10 +5,10 @@ import ( "errors" "time" + "git.runcible.io/learning/pulley/internal/database" "git.runcible.io/learning/pulley/internal/validator" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgconn" - "github.com/jackc/pgx/v5/pgxpool" ) // MUST export fields to serialize them @@ -24,13 +24,13 @@ type Movie struct { // be considered zero and omitted -- and the MarshalJSON() method we just made // won't be called at all. // VSCode will complain though about reflection issue - Runtime Runtime `json:"runtime,omitzero` + Runtime Runtime `json:"runtime,omitzero"` Genres []string `json:"genres,omitzero"` Version int32 `json:"version"` } type MovieModel struct { - pool *pgxpool.Pool + db database.PgxIface } func (m MovieModel) Insert(ctx context.Context, movie *Movie) error { @@ -41,7 +41,7 @@ func (m MovieModel) Insert(ctx context.Context, movie *Movie) error { args := []any{movie.Title, movie.Year, movie.Runtime, movie.Genres} - row := m.pool.QueryRow(ctx, query, args...) + row := m.db.QueryRow(ctx, query, args...) // Insert is mutating the Movie struct err := row.Scan(&movie.ID, &movie.CreatedAt, &movie.Version) return err @@ -68,7 +68,7 @@ func (m MovieModel) Get(ctx context.Context, id int64) (*Movie, error) { var movie Movie - err := m.pool.QueryRow(ctx, query, id).Scan( + err := m.db.QueryRow(ctx, query, id).Scan( // &[]byte{}, // throwaway the pg_sleep value &movie.ID, &movie.CreatedAt, @@ -115,7 +115,7 @@ func (m MovieModel) Update(ctx context.Context, movie *Movie) error { } // Will not return any rows if the version number has already changed. - err := m.pool.QueryRow(ctx, query, args...).Scan(&movie.Version) + err := m.db.QueryRow(ctx, query, args...).Scan(&movie.Version) if err != nil { switch { case errors.Is(err, pgx.ErrNoRows): @@ -142,7 +142,7 @@ func (m MovieModel) Delete(ctx context.Context, id int64) (err error) { query := `DELETE FROM movies WHERE id = $1` - tx, err := m.pool.BeginTx(ctx, pgx.TxOptions{}) + tx, err := m.db.BeginTx(ctx, pgx.TxOptions{}) if err != nil { return err } @@ -169,14 +169,21 @@ func (m MovieModel) Delete(ctx context.Context, id int64) (err error) { return nil } -func (m MovieModel) List(ctx context.Context) ([]*Movie, error) { +func (m MovieModel) GetAll(ctx context.Context, title string, genres []string, filters Filters) ([]*Movie, error) { query := ` SELECT id, created_at, title, year, runtime, genres, version FROM movies ORDER BY id ASC ` - rows, err := m.pool.Query(ctx, query) + + // ctx want some timeout for queries. When used in the handler the context passed should + // be the r.Context. Since cancel functions are inherited it will cancel on client + // disconnect or on time out. + ctxTimeout, cancel := context.WithTimeout(ctx, 3*time.Second) + defer cancel() + + rows, err := m.db.Query(ctxTimeout, query) if err != nil { return nil, err } diff --git a/internal/database/database.go b/internal/database/database.go index 13e7f2c..5d093ef 100644 --- a/internal/database/database.go +++ b/internal/database/database.go @@ -6,6 +6,7 @@ import ( "time" "git.runcible.io/learning/pulley/internal/config" + pgx "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" ) @@ -35,3 +36,26 @@ func OpenPgPool(ctx context.Context, cfg config.ServiceConfig) (*pgxpool.Pool, e } return pool, nil } + +type PgxIface interface { + Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error) + QueryRow(ctx context.Context, sql string, args ...any) pgx.Row + BeginTx(ctx context.Context, txOptions pgx.TxOptions) (pgx.Tx, error) +} + +// TODO determine if this is even useful. Sqlc does produce +type DBQuierier struct { + db *pgxpool.Pool +} + +func (q *DBQuierier) Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error) { + return q.db.Query(ctx, sql, args...) +} + +func (q *DBQuierier) QueryRow(ctx context.Context, sql string, args ...any) pgx.Row { + return q.db.QueryRow(ctx, sql, args...) +} + +func (q *DBQuierier) BeginTx(ctx context.Context, txOptions pgx.TxOptions) (pgx.Tx, error) { + return q.db.BeginTx(ctx, txOptions) +} diff --git a/migrations/000001_create_movies_table.up.sql b/migrations/000001_create_movies_table.up.sql index 312223d..aef9d5c 100644 --- a/migrations/000001_create_movies_table.up.sql +++ b/migrations/000001_create_movies_table.up.sql @@ -4,7 +4,7 @@ title text NOT NULL, year integer NOT NULL, runtime integer NOT NULL, - genres text[] NOT NULL, + version integer NOT NULL DEFAULT 1 );