Engine Handler HTTP Response Tests (#13)

* update handler signatures to use specific store interfaces

* allow skipping Docker-based integration tests for local unit runs
This commit is contained in:
Ian-J-S 2026-02-26 20:19:48 -08:00 committed by GitHub
parent 64236c99c9
commit ecae3699f1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 249 additions and 8 deletions

View file

@ -61,6 +61,11 @@ func getTestGetenv(resource *dockertest.Resource) func(string) string {
}
func TestMain(m *testing.M) {
// Allow skipping Docker-based integration setup for local unit test runs.
if os.Getenv("KOITO_SKIP_DOCKER") == "1" {
log.Println("KOITO_SKIP_DOCKER=1 set; skipping Docker-based tests in engine_test")
os.Exit(m.Run())
}
// uses a sensible default on windows (tcp/http) and linux/osx (socket)
pool, err := dockertest.NewPool("")
if err != nil {

View file

@ -13,7 +13,7 @@ import (
"golang.org/x/crypto/bcrypt"
)
func LoginHandler(store db.DB) http.HandlerFunc {
func LoginHandler(store LoginStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
l := logger.FromContext(ctx)
@ -78,7 +78,7 @@ func LoginHandler(store db.DB) http.HandlerFunc {
}
}
func LogoutHandler(store db.DB) http.HandlerFunc {
func LogoutHandler(store SessionStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
l := logger.FromContext(ctx)
@ -108,7 +108,7 @@ func LogoutHandler(store db.DB) http.HandlerFunc {
}
}
func MeHandler(store db.DB) http.HandlerFunc {
func MeHandler() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
l := logger.FromContext(ctx)
@ -127,7 +127,7 @@ func MeHandler(store db.DB) http.HandlerFunc {
}
}
func UpdateUserHandler(store db.DB) http.HandlerFunc {
func UpdateUserHandler(store UserUpdater) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
l := logger.FromContext(ctx)

View file

@ -9,7 +9,7 @@ import (
"github.com/gabehf/koito/internal/utils"
)
func GetAlbumHandler(store db.DB) func(w http.ResponseWriter, r *http.Request) {
func GetAlbumHandler(store AlbumStore) func(w http.ResponseWriter, r *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
l := logger.FromContext(ctx)

View file

@ -9,7 +9,7 @@ import (
"github.com/gabehf/koito/internal/utils"
)
func GetArtistHandler(store db.DB) func(w http.ResponseWriter, r *http.Request) {
func GetArtistHandler(store ArtistStore) func(w http.ResponseWriter, r *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
l := logger.FromContext(ctx)

View file

@ -9,7 +9,7 @@ import (
"github.com/gabehf/koito/internal/utils"
)
func GetTrackHandler(store db.DB) func(w http.ResponseWriter, r *http.Request) {
func GetTrackHandler(store TrackStore) func(w http.ResponseWriter, r *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
l := logger.FromContext(ctx)

View file

@ -0,0 +1,187 @@
package handlers
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"golang.org/x/crypto/bcrypt"
"github.com/gabehf/koito/engine/middleware"
"github.com/gabehf/koito/internal/models"
"github.com/gabehf/koito/internal/db"
"github.com/google/uuid"
)
// Focused, small test set that avoids needing a full db.DB mock.
func TestHealthHandler_Returns200(t *testing.T) {
rr := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/health", nil)
HealthHandler().ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", rr.Code)
}
}
func TestMeHandler_Unauthorized(t *testing.T) {
rr := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/me", nil)
// MeHandler does not use the store for the unauthorized path, pass nil
MeHandler().ServeHTTP(rr, req)
if rr.Code != http.StatusUnauthorized {
t.Fatalf("expected 401, got %d", rr.Code)
}
}
func TestMeHandler_Success(t *testing.T) {
rr := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/me", nil)
user := &models.User{ID: 1, Username: "testuser"}
ctx := context.WithValue(req.Context(), middleware.UserContextKey, user)
req = req.WithContext(ctx)
MeHandler().ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", rr.Code)
}
if !strings.Contains(rr.Body.String(), "testuser") {
t.Fatalf("expected response to contain username, got %s", rr.Body.String())
}
}
func TestGetArtistHandler_MissingID_Returns400(t *testing.T) {
rr := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/artist", nil)
// Handler will validate query param before touching the store; pass nil
http.HandlerFunc(GetArtistHandler(nil)).ServeHTTP(rr, req)
if rr.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", rr.Code)
}
}
func TestGetArtistHandler_InvalidID_Returns400(t *testing.T) {
rr := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/artist?id=abc", nil)
http.HandlerFunc(GetArtistHandler(nil)).ServeHTTP(rr, req)
if rr.Code != http.StatusBadRequest {
t.Fatalf("expected 400 for invalid id, got %d", rr.Code)
}
}
func TestGetTrackHandler_MissingID_Returns400(t *testing.T) {
rr := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/track", nil)
http.HandlerFunc(GetTrackHandler(nil)).ServeHTTP(rr, req)
if rr.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", rr.Code)
}
}
func TestGetTrackHandler_InvalidID_Returns400(t *testing.T) {
rr := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/track?id=xyz", nil)
http.HandlerFunc(GetTrackHandler(nil)).ServeHTTP(rr, req)
if rr.Code != http.StatusBadRequest {
t.Fatalf("expected 400 for invalid id, got %d", rr.Code)
}
}
func TestGetArtistHandler_Success(t *testing.T) {
rr := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/artist?id=5", nil)
mock := artistStoreMock{}
http.HandlerFunc(GetArtistHandler(mock)).ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", rr.Code)
}
if !strings.Contains(rr.Body.String(), "Test Artist") {
t.Fatalf("expected body to contain artist name, got %s", rr.Body.String())
}
}
func TestGetTrackHandler_Success(t *testing.T) {
rr := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/track?id=7", nil)
mock := trackStoreMock{}
http.HandlerFunc(GetTrackHandler(mock)).ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", rr.Code)
}
if !strings.Contains(rr.Body.String(), "Test Track") {
t.Fatalf("expected body to contain track title, got %s", rr.Body.String())
}
}
func TestLoginHandler_Success(t *testing.T) {
// prepare hashed password
pass := []byte("secretpass")
hashed, _ := bcrypt.GenerateFromPassword(pass, bcrypt.DefaultCost)
store := &loginStoreMock{user: &models.User{ID: 3, Username: "bob", Password: hashed}}
form := "username=bob&password=secretpass"
rr := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(form))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
LoginHandler(store).ServeHTTP(rr, req)
if rr.Code != http.StatusNoContent {
t.Fatalf("expected 204, got %d", rr.Code)
}
// cookie set
found := false
for _, c := range rr.Result().Cookies() {
if c.Name == "koito_session" && c.Value != "" {
found = true
}
}
if !found {
t.Fatalf("expected koito_session cookie to be set")
}
}
// --- minimal mocks ---
type artistStoreMock struct{}
func (artistStoreMock) GetArtist(ctx context.Context, opts db.GetArtistOpts) (*models.Artist, error) {
return &models.Artist{ID: opts.ID, Name: "Test Artist"}, nil
}
type trackStoreMock struct{}
func (trackStoreMock) GetTrack(ctx context.Context, opts db.GetTrackOpts) (*models.Track, error) {
return &models.Track{ID: opts.ID, Title: "Test Track"}, nil
}
type loginStoreMock struct{ user *models.User }
func (l *loginStoreMock) GetUserByUsername(ctx context.Context, username string) (*models.User, error) {
if l.user != nil && l.user.Username == username {
return l.user, nil
}
return nil, nil
}
func (l *loginStoreMock) SaveSession(ctx context.Context, userId int32, expiresAt time.Time, persistent bool) (*models.Session, error) {
return &models.Session{ID: uuid.New(), UserID: userId, ExpiresAt: expiresAt, Persistent: persistent}, nil
}

View file

@ -0,0 +1,37 @@
package handlers
import (
"context"
"time"
"github.com/gabehf/koito/internal/db"
"github.com/gabehf/koito/internal/models"
"github.com/google/uuid"
)
// Focused interfaces for handlers to depend on smaller contracts.
type ArtistStore interface {
GetArtist(ctx context.Context, opts db.GetArtistOpts) (*models.Artist, error)
}
type AlbumStore interface {
GetAlbum(ctx context.Context, opts db.GetAlbumOpts) (*models.Album, error)
}
type TrackStore interface {
GetTrack(ctx context.Context, opts db.GetTrackOpts) (*models.Track, error)
}
type LoginStore interface {
GetUserByUsername(ctx context.Context, username string) (*models.User, error)
SaveSession(ctx context.Context, userId int32, expiresAt time.Time, persistent bool) (*models.Session, error)
}
type SessionStore interface {
DeleteSession(ctx context.Context, sessionId uuid.UUID) error
RefreshSession(ctx context.Context, sessionId uuid.UUID, expiresAt time.Time) error
}
type UserUpdater interface {
UpdateUser(ctx context.Context, opts db.UpdateUserOpts) error
}

View file

@ -98,7 +98,7 @@ func bindRoutes(
r.Post("/user/apikeys", handlers.GenerateApiKeyHandler(db))
r.Patch("/user/apikeys", handlers.UpdateApiKeyLabelHandler(db))
r.Delete("/user/apikeys", handlers.DeleteApiKeyHandler(db))
r.Get("/user/me", handlers.MeHandler(db))
r.Get("/user/me", handlers.MeHandler())
r.Patch("/user", handlers.UpdateUserHandler(db))
})
})

View file

@ -256,6 +256,12 @@ func setupTestDataSansMbzIDs(t *testing.T) {
}
func TestMain(m *testing.M) {
// Allow skipping Docker-based integration setup for local unit test runs.
if os.Getenv("KOITO_SKIP_DOCKER") == "1" {
log.Println("KOITO_SKIP_DOCKER=1 set; skipping Docker-based tests in catalog_test")
os.Exit(m.Run())
}
pool, err := dockertest.NewPool("")
if err != nil {
log.Fatalf("Could not construct pool: %s", err)

View file

@ -4,6 +4,7 @@ import (
"context"
"fmt"
"log"
"os"
"testing"
"github.com/gabehf/koito/internal/cfg"
@ -27,6 +28,11 @@ func getTestGetenv(resource *dockertest.Resource) func(string) string {
}
func TestMain(m *testing.M) {
// Allow skipping Docker-based integration setup for local unit test runs.
if os.Getenv("KOITO_SKIP_DOCKER") == "1" {
log.Println("KOITO_SKIP_DOCKER=1 set; skipping Docker-based tests in psql_test")
os.Exit(m.Run())
}
// uses a sensible default on windows (tcp/http) and linux/osx (socket)
pool, err := dockertest.NewPool("")
if err != nil {