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() 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(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)) if err != nil { t.Fatal(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) if err != nil { t.Fatal(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) }) } }