package api import ( "context" "net/http" "net/http/httptest" "strings" "testing" "github.com/danielgtaylor/huma/v2" "github.com/danielgtaylor/huma/v2/adapters/humaecho" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgconn" "github.com/labstack/echo/v4" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "completed/internal/auth" "completed/internal/db" ) // newTestAPI wires mockDB into a fresh Huma/Echo instance exactly as // cmd/api/main.go does, so tests exercise routing and middleware, not just // the handler bodies. func newTestAPI(mockDB db.Querier) (*echo.Echo, huma.API) { e := echo.New() api := humaecho.New(e, huma.DefaultConfig("Test API", "1.0.0")) RegisterRoutes(api, mockDB) return e, api } // a fixed valid session, returned by GetSessionAndUserFunc so authed // requests don't need a real database. var testUserID = uuid.New() func authedMock() *MockQuerier { return &MockQuerier{ GetSessionAndUserFunc: func(ctx context.Context, tokenHash []byte) (db.GetSessionAndUserRow, error) { return db.GetSessionAndUserRow{ID: testUserID, Email: "a@example.com", Name: "Alice"}, nil }, TouchSessionFunc: func(ctx context.Context, tokenHash []byte) error { return nil }, } } // adminMock is authedMock for a session belonging to an admin, for the // platform-mutation routes that require one. func adminMock() *MockQuerier { m := authedMock() m.GetSessionAndUserFunc = func(ctx context.Context, tokenHash []byte) (db.GetSessionAndUserRow, error) { return db.GetSessionAndUserRow{ID: testUserID, Email: "a@example.com", Name: "Alice", IsAdmin: true}, nil } return m } func authedRequest(method, path, body string) *http.Request { var req *http.Request if body == "" { req = httptest.NewRequest(method, path, nil) } else { req = httptest.NewRequest(method, path, strings.NewReader(body)) req.Header.Set("Content-Type", "application/json") } req.Header.Set("Authorization", "Bearer any-token-the-mock-accepts") return req } func TestListGamesRequiresAuth(t *testing.T) { e, _ := newTestAPI(&MockQuerier{}) req := httptest.NewRequest(http.MethodGet, "/api/games", nil) rec := httptest.NewRecorder() e.ServeHTTP(rec, req) assert.Equal(t, http.StatusUnauthorized, rec.Code) } func TestListGames(t *testing.T) { mockDB := authedMock() mockDB.ListGamesFunc = func(ctx context.Context, userID uuid.UUID) ([]db.ListGamesRow, error) { assert.Equal(t, testUserID, userID, "handler must scope the query to the authenticated user") return []db.ListGamesRow{ {ID: uuid.New(), Name: "Test Game", Score: 90, PlatformName: "PC"}, }, nil } e, _ := newTestAPI(mockDB) rec := httptest.NewRecorder() e.ServeHTTP(rec, authedRequest(http.MethodGet, "/api/games", "")) assert.Equal(t, http.StatusOK, rec.Code) assert.Contains(t, rec.Body.String(), "Test Game") assert.Contains(t, rec.Body.String(), `"platform_name":"PC"`) } func TestGetGameNotFoundForOtherUsersGame(t *testing.T) { // A game that exists, but belongs to someone else: the query is scoped // by user_id, so sqlc/postgres report it as not found, same as a // genuinely missing id. The handler must turn that into 404, not 500. mockDB := authedMock() mockDB.GetGameFunc = func(ctx context.Context, arg db.GetGameParams) (db.GetGameRow, error) { assert.Equal(t, testUserID, arg.UserID, "handler must scope the lookup to the authenticated user") return db.GetGameRow{}, pgx.ErrNoRows } e, _ := newTestAPI(mockDB) rec := httptest.NewRecorder() e.ServeHTTP(rec, authedRequest(http.MethodGet, "/api/games/"+uuid.New().String(), "")) assert.Equal(t, http.StatusNotFound, rec.Code) } func TestDeleteGameNotFoundWhenNoRowsAffected(t *testing.T) { mockDB := authedMock() mockDB.DeleteGameFunc = func(ctx context.Context, arg db.DeleteGameParams) (int64, error) { return 0, nil } e, _ := newTestAPI(mockDB) rec := httptest.NewRecorder() e.ServeHTTP(rec, authedRequest(http.MethodDelete, "/api/games/"+uuid.New().String(), "")) assert.Equal(t, http.StatusNotFound, rec.Code) } func TestDeletePlatformConflictWhenReferencedByGames(t *testing.T) { // Soft delete means the FK is never actually checked by Postgres (see // db/query.sql's DeletePlatform comment), so the handler has to notice // the 0-rows result itself and turn it into 409, not just pass through // whatever error (if any) the query returns. mockDB := adminMock() mockDB.GetPlatformFunc = func(ctx context.Context, id uuid.UUID) (db.GetPlatformRow, error) { return db.GetPlatformRow{ID: id, Name: "PC"}, nil } mockDB.DeletePlatformFunc = func(ctx context.Context, id uuid.UUID) (int64, error) { return 0, nil } e, _ := newTestAPI(mockDB) rec := httptest.NewRecorder() e.ServeHTTP(rec, authedRequest(http.MethodDelete, "/api/platforms/"+uuid.New().String(), "")) assert.Equal(t, http.StatusConflict, rec.Code) } func TestDeletePlatformNotFound(t *testing.T) { mockDB := adminMock() mockDB.GetPlatformFunc = func(ctx context.Context, id uuid.UUID) (db.GetPlatformRow, error) { return db.GetPlatformRow{}, pgx.ErrNoRows } e, _ := newTestAPI(mockDB) rec := httptest.NewRecorder() e.ServeHTTP(rec, authedRequest(http.MethodDelete, "/api/platforms/"+uuid.New().String(), "")) assert.Equal(t, http.StatusNotFound, rec.Code) } func TestCreateGameRejectsScoreOutOfRange(t *testing.T) { mockDB := authedMock() // No CreateGameFunc set: validation must reject the request before the // handler ever calls the database, or this test would panic. e, _ := newTestAPI(mockDB) rec := httptest.NewRecorder() body := `{"name":"Bad","platform_id":"` + uuid.New().String() + `","score":150,"release_date":"2020-01-01"}` e.ServeHTTP(rec, authedRequest(http.MethodPost, "/api/games", body)) assert.Equal(t, http.StatusUnprocessableEntity, rec.Code) } func TestCreatePlatform(t *testing.T) { mockDB := adminMock() mockDB.CreatePlatformFunc = func(ctx context.Context, name string) (db.CreatePlatformRow, error) { return db.CreatePlatformRow{ID: uuid.New(), Name: name}, nil } e, _ := newTestAPI(mockDB) rec := httptest.NewRecorder() e.ServeHTTP(rec, authedRequest(http.MethodPost, "/api/platforms", `{"name": "Sega Genesis"}`)) assert.Equal(t, http.StatusOK, rec.Code) assert.Contains(t, rec.Body.String(), "Sega Genesis") } func TestCreatePlatformForbiddenForNonAdmin(t *testing.T) { mockDB := authedMock() // No CreatePlatformFunc set: the admin check must reject the request // before the handler ever calls the database, or this test would panic. e, _ := newTestAPI(mockDB) rec := httptest.NewRecorder() e.ServeHTTP(rec, authedRequest(http.MethodPost, "/api/platforms", `{"name": "Sega Genesis"}`)) assert.Equal(t, http.StatusForbidden, rec.Code) } func TestListPlatformsIsSharedAcrossUsers(t *testing.T) { // Unlike games, the platform list has no user_id to scope by: every // authenticated user sees the same rows. ListPlatformsFunc taking no // user id (beyond ctx) is itself part of what's being checked here - // this test would fail to compile if a user_id parameter crept back in. calls := 0 mockDB := authedMock() mockDB.ListPlatformsFunc = func(ctx context.Context) ([]db.ListPlatformsRow, error) { calls++ return []db.ListPlatformsRow{{ID: uuid.New(), Name: "PC"}}, nil } e, _ := newTestAPI(mockDB) rec := httptest.NewRecorder() e.ServeHTTP(rec, authedRequest(http.MethodGet, "/api/platforms", "")) assert.Equal(t, http.StatusOK, rec.Code) assert.Contains(t, rec.Body.String(), "PC") assert.Equal(t, 1, calls) } func TestRegisterDuplicateEmailConflicts(t *testing.T) { mockDB := &MockQuerier{ CreateUserFunc: func(ctx context.Context, arg db.CreateUserParams) (db.CreateUserRow, error) { return db.CreateUserRow{}, &pgconn.PgError{Code: pgCodeUniqueViolation} }, } e, _ := newTestAPI(mockDB) rec := httptest.NewRecorder() body := `{"email":"a@example.com","password":"password123","name":"Alice"}` e.ServeHTTP(rec, authedRequest(http.MethodPost, "/api/auth/register", body)) assert.Equal(t, http.StatusConflict, rec.Code) } func TestLoginWrongPasswordUnauthorized(t *testing.T) { hash, err := auth.HashPassword("correct-password") require.NoError(t, err) mockDB := &MockQuerier{ GetUserByEmailFunc: func(ctx context.Context, email string) (db.GetUserByEmailRow, error) { return db.GetUserByEmailRow{ID: testUserID, Email: email, PasswordHash: hash}, nil }, } e, _ := newTestAPI(mockDB) rec := httptest.NewRecorder() body := `{"email":"a@example.com","password":"wrong-password"}` e.ServeHTTP(rec, authedRequest(http.MethodPost, "/api/auth/login", body)) assert.Equal(t, http.StatusUnauthorized, rec.Code) } func TestLoginSuccessSetsSessionCookie(t *testing.T) { hash, err := auth.HashPassword("correct-password") require.NoError(t, err) mockDB := &MockQuerier{ GetUserByEmailFunc: func(ctx context.Context, email string) (db.GetUserByEmailRow, error) { return db.GetUserByEmailRow{ID: testUserID, Email: email, Name: "Alice", PasswordHash: hash}, nil }, CreateSessionFunc: func(ctx context.Context, arg db.CreateSessionParams) error { assert.Equal(t, testUserID, arg.UserID) return nil }, } e, _ := newTestAPI(mockDB) rec := httptest.NewRecorder() body := `{"email":"a@example.com","password":"correct-password"}` e.ServeHTTP(rec, authedRequest(http.MethodPost, "/api/auth/login", body)) assert.Equal(t, http.StatusOK, rec.Code) assert.Contains(t, rec.Header().Get("Set-Cookie"), "session=") }