mirror of
https://github.com/gabehf/Koito.git
synced 2026-03-07 21:48:18 -08:00
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:
parent
64236c99c9
commit
ecae3699f1
10 changed files with 249 additions and 8 deletions
|
|
@ -61,6 +61,11 @@ func getTestGetenv(resource *dockertest.Resource) func(string) string {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestMain(m *testing.M) {
|
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)
|
// uses a sensible default on windows (tcp/http) and linux/osx (socket)
|
||||||
pool, err := dockertest.NewPool("")
|
pool, err := dockertest.NewPool("")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ import (
|
||||||
"golang.org/x/crypto/bcrypt"
|
"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) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
ctx := r.Context()
|
ctx := r.Context()
|
||||||
l := logger.FromContext(ctx)
|
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) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
ctx := r.Context()
|
ctx := r.Context()
|
||||||
l := logger.FromContext(ctx)
|
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) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
ctx := r.Context()
|
ctx := r.Context()
|
||||||
l := logger.FromContext(ctx)
|
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) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
ctx := r.Context()
|
ctx := r.Context()
|
||||||
l := logger.FromContext(ctx)
|
l := logger.FromContext(ctx)
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ import (
|
||||||
"github.com/gabehf/koito/internal/utils"
|
"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) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
ctx := r.Context()
|
ctx := r.Context()
|
||||||
l := logger.FromContext(ctx)
|
l := logger.FromContext(ctx)
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ import (
|
||||||
"github.com/gabehf/koito/internal/utils"
|
"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) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
ctx := r.Context()
|
ctx := r.Context()
|
||||||
l := logger.FromContext(ctx)
|
l := logger.FromContext(ctx)
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ import (
|
||||||
"github.com/gabehf/koito/internal/utils"
|
"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) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
ctx := r.Context()
|
ctx := r.Context()
|
||||||
l := logger.FromContext(ctx)
|
l := logger.FromContext(ctx)
|
||||||
|
|
|
||||||
187
engine/handlers/handlers_test.go
Normal file
187
engine/handlers/handlers_test.go
Normal 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
|
||||||
|
}
|
||||||
37
engine/handlers/store_interfaces.go
Normal file
37
engine/handlers/store_interfaces.go
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -98,7 +98,7 @@ func bindRoutes(
|
||||||
r.Post("/user/apikeys", handlers.GenerateApiKeyHandler(db))
|
r.Post("/user/apikeys", handlers.GenerateApiKeyHandler(db))
|
||||||
r.Patch("/user/apikeys", handlers.UpdateApiKeyLabelHandler(db))
|
r.Patch("/user/apikeys", handlers.UpdateApiKeyLabelHandler(db))
|
||||||
r.Delete("/user/apikeys", handlers.DeleteApiKeyHandler(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))
|
r.Patch("/user", handlers.UpdateUserHandler(db))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -256,6 +256,12 @@ func setupTestDataSansMbzIDs(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestMain(m *testing.M) {
|
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("")
|
pool, err := dockertest.NewPool("")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Could not construct pool: %s", err)
|
log.Fatalf("Could not construct pool: %s", err)
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
|
"os"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/gabehf/koito/internal/cfg"
|
"github.com/gabehf/koito/internal/cfg"
|
||||||
|
|
@ -27,6 +28,11 @@ func getTestGetenv(resource *dockertest.Resource) func(string) string {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestMain(m *testing.M) {
|
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)
|
// uses a sensible default on windows (tcp/http) and linux/osx (socket)
|
||||||
pool, err := dockertest.NewPool("")
|
pool, err := dockertest.NewPool("")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue