Files
Completed/internal/api/routes_test.go
T
SansanandClaude Sonnet 5 7fbd403d30 Make the platform list shared across users; keep games private
Platforms no longer have a user_id: there's one global list, unique by
name, that every authenticated user reads from and writes to. Games are
unchanged - still private per user, still referencing platform_id.

- Migration: dropped platform.user_id and its FK, UNIQUE(user_id, name) ->
  UNIQUE(name), index on change_seq alone instead of (user_id, change_seq).
  Amended the one init migration in place rather than stacking a second one,
  since nothing is deployed yet.
- db/query.sql: platform queries no longer take a user_id parameter.
  DeletePlatform's NOT EXISTS guard already checked games from any user (it
  never filtered the subquery by user_id), so deleting a shared platform is
  correctly blocked by any user's games on it, not just the deleter's.
- routes.go: platform handlers still require auth (the list isn't public to
  anonymous requests) but no longer scope by user.
- Tests: updated the mocks for the new signatures, added
  TestListPlatformsIsSharedAcrossUsers.

Verified against a real Postgres: user A creates "PC", user B sees it
immediately, both can add games against it, each only sees their own
games, deleting the platform is blocked while either user has a game on
it, and the name stays globally unique.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-03 11:34:47 +02:00

252 lines
8.5 KiB
Go

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 },
}
}
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 := authedMock()
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 := authedMock()
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 := authedMock()
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 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=")
}