mirror of
https://github.com/gabehf/Koito.git
synced 2026-03-07 13:38:15 -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) {
|
||||
// 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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
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.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))
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue