package main import ( "bytes" "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" ) 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() assert.NilError(t, err) defer mockPool.Close() r, err := http.NewRequest(http.MethodGet, "/v1/healthcheck", nil) assert.NilError(t, err) app := newTestApplication(mockPool) app.routes().ServeHTTP(respRec, r) resp := respRec.Result() if resp.StatusCode != http.StatusOK { t.Fatalf("Got status code %q, wanted status code %q", resp.StatusCode, http.StatusOK) } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) assert.NilError(t, err) jsonContent := make(map[string]any) json.Unmarshal(body, &jsonContent) assert.Equal(t, jsonContent["status"], "available") } func TestCreateMovieHandler(t *testing.T) { respRec := httptest.NewRecorder() mockPool, err := pgxmock.NewPool() if err != nil { t.Fatal(err) } defer mockPool.Close() 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", bytes.NewBuffer(rquestBody)) assert.NilError(t, err) app := newTestApplication(mockPool) app.routes().ServeHTTP(respRec, r) resp := respRec.Result() assert.Equal(t, resp.StatusCode, http.StatusCreated) defer resp.Body.Close() body, err := io.ReadAll(resp.Body) assert.NilError(t, err) 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 TestCreateMovieValidatorError(t *testing.T) { mockPool, err := pgxmock.NewPool() if err != nil { t.Fatal(err) } defer mockPool.Close() tests := []struct { name string input *strings.Reader wantBody string wantCode int }{ { name: "Test XML", input: strings.NewReader(`Alex`), wantBody: "body contains badly-formed JSON", wantCode: http.StatusBadRequest, }, { name: "Test Bad JSON", input: strings.NewReader(`{"title": "Moana", }`), wantBody: "body contains badly-formed JSON", wantCode: http.StatusBadRequest, }, { name: "Send a JSON array instead of an object", input: strings.NewReader(`["not", "good"]`), wantBody: "body contains incorrect JSON type", wantCode: http.StatusBadRequest, }, { name: "Send a numeric 'title' value", input: strings.NewReader(`{"title": 123}`), wantBody: "body contains incorrect JSON type", wantCode: http.StatusBadRequest, }, { name: "Send an empty request body", input: strings.NewReader(""), wantBody: "body must not be empty", wantCode: http.StatusBadRequest, }, { name: "Send unknown field", input: strings.NewReader(`{"title": "Moana", "year": 2019, "runtime": "120 mins", "genres": ["family", "Samoan"], "rating": "PG"}`), wantBody: "body contains unknown key", wantCode: http.StatusBadRequest, }, { name: "Send garbage after JSON", input: strings.NewReader(`{"title": "Moana"} :~()`), wantBody: "body must only contain a single JSON value", wantCode: http.StatusBadRequest, }, { name: "Send too large a JSON payload", // 1.5 MB title input: strings.NewReader(fmt.Sprintf("{\"title\": \"%s\"}", strings.Repeat("a", int(1.5*1024*1024)))), wantBody: "body must not be larger than 1048576 bytes", wantCode: http.StatusBadRequest, }, { name: "Send invalid runtime", input: strings.NewReader(`{"title": "Moana", "runtime": 120}`), wantBody: "invalid runtime format", wantCode: http.StatusBadRequest, }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { respRec := httptest.NewRecorder() r, err := http.NewRequest(http.MethodPost, "/v1/movies", test.input) assert.NilError(t, err) app := newTestApplication(mockPool) app.routes().ServeHTTP(respRec, r) resp := respRec.Result() assert.Equal(t, resp.StatusCode, test.wantCode) var jsonResp map[string]string json.NewDecoder(resp.Body).Decode(&jsonResp) assert.StringContains(t, jsonResp["error"], test.wantBody) }) } } 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 wantCode int useID bool }{ { name: "Get Movie By ID", 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: 301, }, { name: "Negative ID", id: "-1", wantCode: 404, }, } 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 TestListMovieHandler(t *testing.T) { respRec := httptest.NewRecorder() mockPool, err := pgxmock.NewPool() if err != nil { t.Fatal(err) } defer mockPool.Close() movies := []data.Movie{{ ID: 1337, CreatedAt: time.Now(), Title: "Batteries not included", Year: 1987, Runtime: data.Runtime(120), Genres: []string{"family", "comedy"}, Version: 1, }, { ID: 1338, CreatedAt: time.Now(), Title: "The Boy and the Heron", Year: 2023, Runtime: data.Runtime(140), Genres: []string{"animation", "drama"}, Version: 1, }, } rows := pgxmock.NewRows([]string{"id", "created_at", "title", "year", "runtime", "genres", "version"}) for _, m := range movies { rows.AddRow(m.ID, m.CreatedAt, m.Title, m.Year, m.Runtime, m.Genres, m.Version) } mockPool.ExpectQuery(`SELECT id, created_at, title, year, runtime, genres, version FROM movies ORDER BY id ASC`).WillReturnRows(rows) r, err := http.NewRequest(http.MethodGet, "/v1/movies", nil) assert.NilError(t, err) app := newTestApplication(mockPool) app.routes().ServeHTTP(respRec, r) resp := respRec.Result() if resp.StatusCode != http.StatusOK { t.Fatalf("Got status code %d, wanted status code %d", resp.StatusCode, http.StatusOK) } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) assert.NilError(t, err) jsonContent := make(map[string][]data.Movie) err = json.Unmarshal(body, &jsonContent) assert.NilError(t, err) assert.Equal(t, len(jsonContent["movies"]), 2) for i := range jsonContent["movies"] { assert.MovieEqual(t, jsonContent["movies"][i], movies[i]) } err = mockPool.ExpectationsWereMet() assert.NilError(t, err) } func TestListHandlerServerError(t *testing.T) { respRec := httptest.NewRecorder() mockPool, err := pgxmock.NewPool() assert.NilError(t, err) getAllReturnColumns := []string{"id", "created_at", "title", "year", "runtime", "genres", "version"} errorRows := pgxmock.NewRows(getAllReturnColumns).AddRow(1, time.Now(), "will error", 2026, 120, []string{}, 1).RowError(0, fmt.Errorf("network connection lost")) mockPool.ExpectQuery("SELECT").WillReturnRows(errorRows) r, err := http.NewRequest(http.MethodGet, "/v1/movies", nil) app := newTestApplication(mockPool) app.routes().ServeHTTP(respRec, r) resp := respRec.Result() assert.Equal(t, resp.StatusCode, http.StatusInternalServerError) } func TestListHandlerValidation(t *testing.T) { mockPool, err := pgxmock.NewPool() assert.NilError(t, err) defer mockPool.Close() getAllReturnColumns := []string{"id", "created_at", "title", "year", "runtime", "genres", "version"} getAllQuery := ` SELECT id, created_at, title, year, runtime, genres, version FROM movies ORDER BY id ASC ` testTable := []struct { name string query string wantCode int }{ // invalid requests { name: "bad sort key", query: "/v1/movies?sort=dirp", wantCode: http.StatusUnprocessableEntity, }, { name: "bad page value", query: "/v1/movies?page=shouldbeint", wantCode: http.StatusUnprocessableEntity, }, { name: "bad page_size value", query: "/v1/movies?page_size=shouldbeint", wantCode: http.StatusUnprocessableEntity, }, { name: "bad page too large", query: "/v1/movies?page=1000000000", wantCode: http.StatusUnprocessableEntity, }, { name: "bad page_size too large", query: "/v1/movies?page_size=1000", wantCode: http.StatusUnprocessableEntity, }, // valid requests { name: "no query params", query: "/v1/movies", wantCode: http.StatusOK, }, { name: "valid page_size", query: "/v1/movies?page_size=50", wantCode: http.StatusOK, }, { name: "valid page", query: "/v1/movies?page=10", wantCode: http.StatusOK, }, { name: "sort by id ascending", query: "/v1/movies?sort=id", wantCode: http.StatusOK, }, { name: "sort by id ascending", query: "/v1/movies?sort=-id", wantCode: http.StatusOK, }, { name: "sort by year ascending", query: "/v1/movies?sort=year", wantCode: http.StatusOK, }, { name: "sort by year descending", query: "/v1/movies?sort=-year", wantCode: http.StatusOK, }, { name: "sort by runtime ascending", query: "/v1/movies?sort=runtime", wantCode: http.StatusOK, }, { name: "sort by runtime descending", query: "/v1/movies?sort=-runtime", wantCode: http.StatusOK, }, { name: "sort by title ascending", query: "/v1/movies?sort=title", wantCode: http.StatusOK, }, { name: "sort by title descending", query: "/v1/movies?sort=-title", wantCode: http.StatusOK, }, } app := newTestApplication(mockPool) for _, test := range testTable { t.Run(test.name, func(t *testing.T) { respRec := httptest.NewRecorder() mockPool, err := pgxmock.NewPool() assert.NilError(t, err) // assert if DB is expected to be hit if test.wantCode == http.StatusOK { // TODO expand return values and parameterize // empty return is fine for validator test mockPool.ExpectQuery(getAllQuery).WillReturnRows(mockPool.NewRows(getAllReturnColumns)) } r, err := http.NewRequest(http.MethodGet, test.query, nil) assert.NilError(t, err) app = newTestApplication(mockPool) app.routes().ServeHTTP(respRec, r) resp := respRec.Result() assert.Equal(t, resp.StatusCode, test.wantCode) err = mockPool.ExpectationsWereMet() assert.NilError(t, err) }) } }