diff --git a/Makefile b/Makefile index 808604f..656abd3 100644 --- a/Makefile +++ b/Makefile @@ -19,7 +19,7 @@ test-short: .PHONY: test-short test-int: - go test $(FLAGS) -count=1 ./cmd/api/handlers_integration_test.go -integration-handlers + go test $(FLAGS) -count=1 -tags=integration ./cmd/api/... .PHONY: test-int ## Coverage See also -covermode=count and -covermode=atomic diff --git a/README.md b/README.md index cfe9aeb..6edda8d 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,23 @@ make start-local air ``` +## Testing + +Unit tests: + +```bash +make test +``` + +Integration tests. Assumes `start-local` has been run and ready or `PULLEY_INTEGRATION_DATABASE_URI` is specified. + +```bash +make test-int +``` + + + + ## Routes diff --git a/cmd/api/handlers_integration_test.go b/cmd/api/handlers_integration_test.go index 580ecdb..e0a9ace 100644 --- a/cmd/api/handlers_integration_test.go +++ b/cmd/api/handlers_integration_test.go @@ -1,18 +1,57 @@ +//go:build integration + package main import ( - "flag" + "encoding/json" + "io" + "net/http" + "net/http/httptest" "testing" -) -var runIntegrationTestHandlers bool - -func init() { - flag.BoolVar(&runIntegrationTestHandlers, "integration-handlers", false, "run integration tests for http handlers") -} + "git.runcible.io/learning/pulley/internal/assert" + "git.runcible.io/learning/pulley/internal/data" + "git.runcible.io/learning/pulley/internal/testutil" +) func TestHttpListHandlers(t *testing.T) { - if !runIntegrationTestHandlers { - t.Skip("Skipping handler integration tests") + pool := testutil.SetupTestDB(t) + + movies := []data.Movie{ + { + ID: 1337, + Title: "Immortals", + Runtime: data.Runtime(110), + Genres: []string{"action", "adventure"}, + Year: 2011, + Version: 1, + }, } + + testutil.SeedMovies(t, pool, movies) + + app := newTestApplication(pool) + + t.Run("test list movies", func(t *testing.T) { + rec := httptest.NewRecorder() + r, err := http.NewRequest(http.MethodGet, "/v1/movies", nil) + assert.NilError(t, err) + + app.routes().ServeHTTP(rec, r) + + resp := rec.Result() + assert.Equal(t, resp.StatusCode, http.StatusOK) + + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + assert.NilError(t, err) + + content := make(map[string][]data.Movie) + err = json.Unmarshal(body, &content) + assert.NilError(t, err) + + assert.Equal(t, len(content["movies"]), 1) + assert.MovieEqual(t, content["movies"][0], movies[0]) + }) + } diff --git a/cmd/api/handlers_test.go b/cmd/api/handlers_test.go index dca1575..2ffca7a 100644 --- a/cmd/api/handlers_test.go +++ b/cmd/api/handlers_test.go @@ -1,3 +1,5 @@ +//go:build !integration + package main import ( @@ -5,35 +7,20 @@ import ( "encoding/json" "fmt" "io" - "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" ) var defaultFixedTime = time.Date(2026, 1, 25, 10, 10, 40, 0, time.UTC) var getAllReturnColumns = []string{"id", "created_at", "title", "year", "runtime", "genres", "version"} -func newTestApplication(pool database.PgxIface) application { - cfg := config.ServiceConfig{Env: "test"} - 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() diff --git a/cmd/api/testutils.go b/cmd/api/testutils.go new file mode 100644 index 0000000..9372745 --- /dev/null +++ b/cmd/api/testutils.go @@ -0,0 +1,21 @@ +package main + +import ( + "log/slog" + "os" + + "git.runcible.io/learning/pulley/internal/config" + "git.runcible.io/learning/pulley/internal/data" + "git.runcible.io/learning/pulley/internal/database" +) + +func newTestApplication(pool database.PgxIface) application { + cfg := config.ServiceConfig{Env: "test"} + 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} + +} diff --git a/internal/testutil/db.go b/internal/testutil/db.go index 3dcc90e..9fa7c73 100644 --- a/internal/testutil/db.go +++ b/internal/testutil/db.go @@ -10,8 +10,10 @@ import ( "time" "git.runcible.io/learning/pulley/internal/config" + "git.runcible.io/learning/pulley/internal/data" "git.runcible.io/learning/pulley/internal/database" "git.runcible.io/learning/pulley/migrations" + "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" ) @@ -109,10 +111,14 @@ func SetupTestDB(t *testing.T) *pgxpool.Pool { t.Cleanup(func() { t.Logf("Ensuring test pool is closed") testPool.Close() - // TODO envvar check could be beneficial if DB needs to be inspected - // because of failure + t.Logf("Cleaning up database %s", testDbName) + if t.Failed() && os.Getenv("PULLEY_PRESERVE_FAILED_DB") == "true" { + t.Logf("Test failed; skipping db cleanup for %s", testDbName) + return + } + ctxCleanup, cleanupCancel := context.WithTimeout(context.Background(), 5*time.Second) defer cleanupCancel() @@ -126,3 +132,35 @@ func SetupTestDB(t *testing.T) *pgxpool.Pool { return testPool } + +// SeedMovie inserts movies into the movies database table +func SeedMovies(t *testing.T, pool *pgxpool.Pool, movies []data.Movie) { + t.Helper() + + ctxTimeout, cancel := context.WithTimeout(context.Background(), 5*time.Second) + + defer cancel() + + tx, err := pool.BeginTx(ctxTimeout, pgx.TxOptions{}) + if err != nil { + t.Fatalf("Failed to begin seed transaction: %v", err) + } + + defer tx.Rollback(ctxTimeout) + + query := ` + INSERT INTO movies (id, title, year, runtime, genres, version) + VALUES ($1, $2, $3, $4, $5, $6)` + + for _, m := range movies { + _, err := tx.Exec(ctxTimeout, query, m.ID, m.Title, m.Year, m.Runtime, m.Genres, m.Version) + if err != nil { + t.Fatalf("failed to insert movie %q: %v", m.Title, err) + } + } + + if err = tx.Commit(ctxTimeout); err != nil { + t.Fatalf("Failed to commit seed transaction: %v", err) + } + +}