#9 Major routes refactor and expansion, #15 Added API for statistics, Added search cache backend and functionality tests, Backend sync improvements
Build / build (push) Successful in 56s

This commit is contained in:
Sansan committed 2026-09-26 16:48:40 +02:00
1 parent e86c0f8e04
commit a8a2602f50
34 files changed
+1819 -1294

No files matched your search

+10
View File
@@ -2,6 +2,7 @@ package middleware
import (
"net/http"
"os"
"strings"
"time"
@@ -17,6 +18,15 @@ import (
func TokenAuthMiddleware(pool *pgxpool.Pool) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c *echo.Context) error {
// Session auth disable bypass - allow all requests through without token validation
disableSessionAuth := os.Getenv("DISABLE_SESSION_AUTH") == "true"
if disableSessionAuth {
logging.GetLogger().Debug("Session auth disabled - bypassing token authentication",
zap.String("path", c.Request().URL.Path),
zap.String("ip", c.RealIP()))
return next(c)
}
// Extract token from Authorization header
authHeader := c.Request().Header.Get("Authorization")
if authHeader == "" {
+137 -28
View File
@@ -7,6 +7,7 @@ import (
"net/http"
"sort"
"strings"
"time"
"github.com/a-h/templ"
"github.com/labstack/echo/v5"
@@ -52,7 +53,6 @@ func (s *Server) RegisterRoutes() http.Handler {
e.GET("/search", echo.WrapHandler(templ.Handler(web.SearchForm())))
e.POST("/find", echo.WrapHandler(http.HandlerFunc(web.FindSoundtrackWebHandler)))
e.POST("/findfuzzy", echo.WrapHandler(http.HandlerFunc(web.FindSoundtrackFuzzyWebHandler)))
e.Static("/", "/frontend")
@@ -117,53 +117,162 @@ func (s *Server) RegisterRoutes() http.Handler {
// Create /api/v1 group
apiV1 := e.Group("/api/v1")
// Public endpoints - no token required
apiV1.POST("/token", func(c *echo.Context) error {
// Create rate limiter for token creation: 10 requests per minute per IP
tokenRateLimiter := middleware.NewRateLimiter(10, time.Minute)
tokenRateLimitMiddleware := middleware.RateLimitMiddleware(tokenRateLimiter)
// Token endpoints (public - no token required)
tokenGroup := apiV1.Group("/token")
tokenGroup.POST("", func(c *echo.Context) error {
return s.tokenHandler.CreateTokenHandler(c)
})
apiV1.DELETE("/token", func(c *echo.Context) error {
}, tokenRateLimitMiddleware)
tokenGroup.DELETE("", func(c *echo.Context) error {
return s.tokenHandler.DeleteTokenHandler(c)
})
apiV1.POST("/token/cleanup", func(c *echo.Context) error {
tokenGroup.POST("/cleanup", func(c *echo.Context) error {
return s.tokenHandler.CleanupExpiredSessionsHandler(c)
})
// Protected endpoints - require valid token
// Create token auth middleware with pool access
tokenAuthMiddleware := middleware.TokenAuthMiddleware(s.db.Pool)
// Protected group with token authentication
protectedV1 := apiV1.Group("", tokenAuthMiddleware)
// Statistics API endpoints (protected by token auth)
statistics := s.statisticsHandler
protectedV1.GET("/statistics/soundtracks/most-played", func(c *echo.Context) error {
return statistics.GetMostPlayedSoundtracks(c)
// Statistics endpoints (protected)
statisticsHandler := s.statisticsHandler
statsGroup := protectedV1.Group("/statistics")
statsGroup.GET("/soundtracks/most-played", func(c *echo.Context) error {
return statisticsHandler.GetMostPlayedSoundtracks(c)
})
protectedV1.GET("/statistics/soundtracks/least-played", func(c *echo.Context) error {
return statistics.GetLeastPlayedSoundtracks(c)
statsGroup.GET("/soundtracks/least-played", func(c *echo.Context) error {
return statisticsHandler.GetLeastPlayedSoundtracks(c)
})
protectedV1.GET("/statistics/soundtracks/never-played", func(c *echo.Context) error {
return statistics.GetNeverPlayedSoundtracks(c)
statsGroup.GET("/soundtracks/never-played", func(c *echo.Context) error {
return statisticsHandler.GetNeverPlayedSoundtracks(c)
})
protectedV1.GET("/statistics/soundtracks/last-played", func(c *echo.Context) error {
return statistics.GetLastPlayedSoundtracks(c)
statsGroup.GET("/soundtracks/last-played", func(c *echo.Context) error {
return statisticsHandler.GetLastPlayedSoundtracks(c)
})
protectedV1.GET("/statistics/soundtracks/oldest-played", func(c *echo.Context) error {
return statistics.GetOldestPlayedSoundtracks(c)
statsGroup.GET("/soundtracks/oldest-played", func(c *echo.Context) error {
return statisticsHandler.GetOldestPlayedSoundtracks(c)
})
protectedV1.GET("/statistics/songs/most-played", func(c *echo.Context) error {
return statistics.GetMostPlayedSongs(c)
statsGroup.GET("/songs/most-played", func(c *echo.Context) error {
return statisticsHandler.GetMostPlayedSongs(c)
})
protectedV1.GET("/statistics/songs/least-played", func(c *echo.Context) error {
return statistics.GetLeastPlayedSongs(c)
statsGroup.GET("/songs/least-played", func(c *echo.Context) error {
return statisticsHandler.GetLeastPlayedSongs(c)
})
protectedV1.GET("/statistics/summary", func(c *echo.Context) error {
return statistics.GetStatisticsSummary(c)
statsGroup.GET("/summary", func(c *echo.Context) error {
return statisticsHandler.GetStatisticsSummary(c)
})
// Future: VGMQ endpoints will be added to protectedV1 group
_ = protectedV1 // Use the variable to avoid unused variable error
// Version endpoints (protected)
versionGroup := protectedV1.Group("/version")
versionGroup.GET("", func(c *echo.Context) error {
return version.GetLatestVersion(c)
})
versionGroup.GET("/history", func(c *echo.Context) error {
return version.GetVersionHistory(c)
})
// Character endpoints (protected)
characterGroup := protectedV1.Group("/character")
characterGroup.GET("", func(c *echo.Context) error {
return character.GetCharacter(c)
})
characterGroup.GET("/list", func(c *echo.Context) error {
return character.GetCharacterList(c)
})
// Download endpoints (protected)
downloadGroup := protectedV1.Group("/download")
downloadGroup.GET("", func(c *echo.Context) error {
return download.checkLatest(c)
})
downloadGroup.GET("/list", func(c *echo.Context) error {
return download.listAssetsOfLatest(c)
})
downloadGroup.GET("/windows", func(c *echo.Context) error {
return download.downloadLatestWindows(c)
})
downloadGroup.GET("/linux", func(c *echo.Context) error {
return download.downloadLatestLinux(c)
})
// Sync endpoints (protected)
protectedSyncGroup := protectedV1.Group("/sync")
protectedSyncGroup.GET("", func(c *echo.Context) error {
return sync.SyncSoundtracksNewOnlyChanges(c)
})
protectedSyncGroup.GET("/progress", func(c *echo.Context) error {
return sync.SyncProgress(c)
})
protectedSyncGroup.GET("/new", func(c *echo.Context) error {
return sync.SyncSoundtracksNewOnlyChanges(c)
})
protectedSyncGroup.GET("/full", func(c *echo.Context) error {
return sync.SyncSoundtracksNewFull(c)
})
protectedSyncGroup.GET("/new/full", func(c *echo.Context) error {
return sync.SyncSoundtracksNewFull(c)
})
protectedSyncGroup.GET("/quick", func(c *echo.Context) error {
return sync.SyncSoundtracksNewOnlyChanges(c)
})
protectedSyncGroup.GET("/reset", func(c *echo.Context) error {
return sync.ResetDB(c)
})
// Music endpoints (protected)
protectedMusicGroup := protectedV1.Group("/music")
protectedMusicGroup.GET("", func(c *echo.Context) error {
return music.GetSong(c)
})
protectedMusicGroup.GET("/soundTest", func(c *echo.Context) error {
return music.GetSoundCheckSong(c)
})
protectedMusicGroup.GET("/reset", func(c *echo.Context) error {
return music.ResetMusic(c)
})
protectedMusicGroup.GET("/rand", func(c *echo.Context) error {
return music.GetRandomSong(c)
})
protectedMusicGroup.GET("/rand/low", func(c *echo.Context) error {
return music.GetRandomSongLowChance(c)
})
protectedMusicGroup.GET("/rand/classic", func(c *echo.Context) error {
return music.GetRandomSongClassic(c)
})
protectedMusicGroup.GET("/info", func(c *echo.Context) error {
return music.GetSongInfo(c)
})
protectedMusicGroup.GET("/list", func(c *echo.Context) error {
return music.GetPlayedSongs(c)
})
protectedMusicGroup.GET("/next", func(c *echo.Context) error {
return music.GetNextSong(c)
})
protectedMusicGroup.GET("/previous", func(c *echo.Context) error {
return music.GetPreviousSong(c)
})
protectedMusicGroup.GET("/all", func(c *echo.Context) error {
return music.GetAllSoundtracksRandom(c)
})
protectedMusicGroup.GET("/all/order", func(c *echo.Context) error {
return music.GetAllSoundtracks(c)
})
protectedMusicGroup.GET("/all/random", func(c *echo.Context) error {
return music.GetAllSoundtracksRandom(c)
})
protectedMusicGroup.PUT("/played", func(c *echo.Context) error {
return music.PutPlayed(c)
})
protectedMusicGroup.GET("/addQue", func(c *echo.Context) error {
return music.AddLatestToQue(c)
})
protectedMusicGroup.GET("/addPlayed", func(c *echo.Context) error {
return music.AddLatestPlayed(c)
})
routes := e.Router().Routes()
sort.Slice(routes, func(i, j int) bool {
+20 -16
View File
@@ -15,19 +15,14 @@ import (
)
type Server struct {
port int
db *db.Database
tokenHandler *TokenHandler
port int
db *db.Database
tokenHandler *TokenHandler
statisticsHandler *StatisticsHandler
httpServer *http.Server
httpServer *http.Server
}
var (
host = os.Getenv("DB_HOST")
dbPort = os.Getenv("DB_PORT")
dbName = os.Getenv("DB_NAME")
username = os.Getenv("DB_USERNAME")
password = os.Getenv("DB_PASSWORD")
musicPath = os.Getenv("MUSIC_PATH")
charactersPath = os.Getenv("CHARACTERS_PATH")
logLevel = os.Getenv("LOG_LEVEL")
@@ -37,11 +32,20 @@ var (
// NewServerInstance creates a new Server instance with all dependencies initialized.
// Use this for dependency injection and proper lifecycle management.
func NewServerInstance() *Server {
// Re-read environment variables at server creation time (not at package import time)
// This allows tests to override environment variables
host := os.Getenv("DB_HOST")
dbPort := os.Getenv("DB_PORT")
dbName := os.Getenv("DB_NAME")
username := os.Getenv("DB_USERNAME")
password := os.Getenv("DB_PASSWORD")
// Initialize logger
if logLevel == "" {
logLevel = "info"
currentLogLevel := logLevel
if currentLogLevel == "" {
currentLogLevel = "info"
}
logging.Init(logLevel, logJSON)
logging.Init(currentLogLevel, logJSON)
logger := logging.GetLogger()
@@ -68,15 +72,15 @@ func NewServerInstance() *Server {
// Initialize token handler with database pool
tokenHandler := NewTokenHandler(database.Pool)
// Initialize statistics handler
statisticsHandler := NewStatisticsHandler()
// Create the server instance
appServer := &Server{
port: port,
db: database,
tokenHandler: tokenHandler,
port: port,
db: database,
tokenHandler: tokenHandler,
statisticsHandler: statisticsHandler,
}
+411
View File
@@ -0,0 +1,411 @@
package server
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/testcontainers/testcontainers-go"
"github.com/testcontainers/testcontainers-go/wait"
)
// saveEnvVars saves the current state of relevant environment variables
func saveEnvVars() map[string]string {
vars := []string{
"DB_HOST", "DB_PORT", "DB_USERNAME", "DB_PASSWORD", "DB_NAME",
"MUSIC_PATH", "CHARACTERS_PATH", "PORT", "DISABLE_SESSION_AUTH", "LOG_LEVEL",
}
saved := make(map[string]string)
for _, v := range vars {
saved[v] = os.Getenv(v)
}
return saved
}
// restoreEnvVars restores environment variables from a saved state
func restoreEnvVars(saved map[string]string) {
for k, v := range saved {
if v == "" {
os.Unsetenv(k)
} else {
os.Setenv(k, v)
}
}
}
// checkTableExists returns true if the specified table exists in the database
func checkTableExists(ctx context.Context, pool *pgxpool.Pool, tableName string) (bool, error) {
var exists bool
err := pool.QueryRow(ctx, `
SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_schema = 'public' AND table_name = $1
)
`, tableName).Scan(&exists)
return exists, err
}
// loadSQLDumpIntoDatabase loads a SQL dump file into a PostgreSQL database
func loadSQLDumpIntoDatabase(t *testing.T, ctx context.Context, container testcontainers.Container, dbname, dumpPath string) error {
// Copy the SQL dump file to the container
basename := filepath.Base(dumpPath)
targetPath := "/tmp/" + basename
if err := container.CopyFileToContainer(ctx, dumpPath, targetPath, 0644); err != nil {
return fmt.Errorf("failed to copy dump file to container: %w", err)
}
// Execute psql to load the dump as the postgres superuser
// We execute psql inside the container, so we use localhost and port 5432 (the container's internal port)
// The postgres user is the superuser created by the Docker image
// Use sh instead of bash as postgres:15-alpine uses Alpine Linux
cmd := []string{"sh", "-c", "PGPASSWORD=postgres psql -h localhost -U postgres -d " + dbname + " -f " + targetPath + " -v ON_ERROR_STOP=1"}
exitCode, reader, err := container.Exec(ctx, cmd)
if err != nil {
return fmt.Errorf("failed to execute psql: %w", err)
}
// Read the output
output, err := io.ReadAll(reader)
if err != nil {
return fmt.Errorf("failed to read psql output: %w", err)
}
if exitCode != 0 {
return fmt.Errorf("psql exited with code %d, output: %s", exitCode, string(output))
}
t.Logf("Successfully loaded SQL dump from %s into database %s", dumpPath, dbname)
return nil
}
// createRoleInContainer creates a role in the PostgreSQL container
func createRoleInContainer(t *testing.T, ctx context.Context, container testcontainers.Container, roleName, superuserPassword string) error {
// Get the mapped port for the container
mappedPort, err := container.MappedPort(ctx, "5432")
if err != nil {
return fmt.Errorf("failed to get mapped port: %w", err)
}
// Connect to the postgres database to create the role
connStr := fmt.Sprintf("host=localhost port=%s user=postgres password=postgres dbname=postgres sslmode=disable",
mappedPort.Port())
pool, err := pgxpool.New(ctx, connStr)
if err != nil {
return fmt.Errorf("failed to connect to postgres database: %w", err)
}
defer pool.Close()
// Check if role already exists
var exists bool
err = pool.QueryRow(ctx, "SELECT 1 FROM pg_roles WHERE rolname = $1", roleName).Scan(&exists)
if err == nil && exists {
return nil // Role already exists
}
// Clear any error from the role check (role might not exist, which is expected)
// Create the role
_, err = pool.Exec(ctx, fmt.Sprintf("CREATE ROLE %s WITH LOGIN PASSWORD 'testpass'", roleName))
if err != nil {
return fmt.Errorf("failed to create role %s: %w", roleName, err)
}
return nil
}
// createDatabaseInContainer creates a new database in the PostgreSQL container
func createDatabaseInContainer(t *testing.T, ctx context.Context, container testcontainers.Container, dbname string) error {
// Get the mapped port for the container
mappedPort, err := container.MappedPort(ctx, "5432")
if err != nil {
return fmt.Errorf("failed to get mapped port: %w", err)
}
// Add a small delay to ensure PostgreSQL is fully ready
time.Sleep(2 * time.Second)
// Connect to the default 'postgres' database to create the new database
// Use localhost and the mapped port
connStr := fmt.Sprintf("host=localhost port=%s user=postgres password=postgres dbname=postgres sslmode=disable",
mappedPort.Port())
pool, err := pgxpool.New(ctx, connStr)
if err != nil {
return fmt.Errorf("failed to connect to postgres database: %w", err)
}
defer pool.Close()
// Check if database already exists and drop it if so
var exists int
err = pool.QueryRow(ctx, "SELECT 1 FROM pg_database WHERE datname = $1", dbname).Scan(&exists)
if err == nil && exists == 1 {
_, err = pool.Exec(ctx, "DROP DATABASE "+dbname+" WITH (FORCE)")
if err != nil {
return fmt.Errorf("failed to drop existing database: %w", err)
}
t.Logf("Dropped existing database %s", dbname)
}
// Create the new database
_, err = pool.Exec(ctx, "CREATE DATABASE "+dbname)
if err != nil {
return fmt.Errorf("failed to create database: %w", err)
}
t.Logf("Created database %s", dbname)
return nil
}
// startPostgreSQLContainer starts a PostgreSQL container and returns it
func startPostgreSQLContainer(t *testing.T, ctx context.Context) (testcontainers.Container, error) {
req := testcontainers.ContainerRequest{
Image: "postgres:15-alpine",
ExposedPorts: []string{"5432/tcp"},
Env: map[string]string{
"POSTGRES_PASSWORD": "postgres",
"POSTGRES_DB": "postgres",
},
WaitingFor: wait.ForLog("database system is ready to accept connections"),
}
container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
ContainerRequest: req,
Started: true,
})
if err != nil {
return nil, fmt.Errorf("failed to start container: %w", err)
}
return container, nil
}
// TestMusicRefDump tests that the application can start with music_ref_dump.sql and migrations work
func TestMusicRefDump(t *testing.T) {
testSQLDump(t, "music_ref_dump.sql", "music_server_ref_test")
}
// TestMusicTestDump tests that the application can start with music_test_dump.sql and migrations work
func TestMusicTestDump(t *testing.T) {
testSQLDump(t, "music_test_dump.sql", "music_server_test_dump_test")
}
// TestMusicProdDump tests that the application can start with music_prod_dump.sql and migrations work
func TestMusicProdDump(t *testing.T) {
testSQLDump(t, "music_prod_dump.sql", "music_server_prod_test")
}
// testSQLDump is the common test function for all SQL dump tests
func testSQLDump(t *testing.T, dumpFile, dbname string) {
ctx := context.Background()
// Save original environment variables
originalEnv := saveEnvVars()
defer restoreEnvVars(originalEnv)
// Start PostgreSQL container
t.Log("Starting PostgreSQL container...")
container, err := startPostgreSQLContainer(t, ctx)
require.NoError(t, err)
defer func() {
t.Log("Stopping container...")
container.Terminate(ctx)
}()
// Get container connection details
endpoint, err := container.Endpoint(ctx, "")
require.NoError(t, err)
t.Logf("Container running at: %s", endpoint)
// Create database for this test
t.Logf("Creating database %s...", dbname)
err = createDatabaseInContainer(t, ctx, container, dbname)
require.NoError(t, err)
// Create common roles that might be referenced in dump files
err = createRoleInContainer(t, ctx, container, "sansan", "postgres")
if err != nil {
t.Logf("Note: Could not create role 'sansan': %v (may not be needed)", err)
}
// Load the SQL dump
// The dump files are in the project root
// Get the absolute path to the dump file
absDumpPath, err := filepath.Abs(filepath.Join("..", "..", dumpFile))
require.NoError(t, err)
if _, err := os.Stat(absDumpPath); os.IsNotExist(err) {
// Try relative to current working directory
absDumpPath = filepath.Join(".", dumpFile)
if _, err := os.Stat(absDumpPath); os.IsNotExist(err) {
absDumpPath = dumpFile // Try as-is
}
}
t.Logf("Loading SQL dump from: %s", absDumpPath)
err = loadSQLDumpIntoDatabase(t, ctx, container, dbname, absDumpPath)
require.NoError(t, err)
// ============================================
// PRE-MIGRATION CHECK
// ============================================
t.Log("Running pre-migration checks...")
// For connecting to the database from the test, we need to use the container's endpoint
// But the container's endpoint might be localhost:random_port
// We need to parse it to get host and port
// endpoint format: "host:port" e.g., "localhost:12345" or "127.0.0.1:23456"
// Actually, for testcontainers with Docker, the endpoint should be localhost:mapped_port
// Let's extract the mapped port
// Get the mapped port from the container
mappedPort, err := container.MappedPort(ctx, "5432")
require.NoError(t, err)
t.Logf("Container mapped port: %s", mappedPort)
// Use localhost as the host since we're on the same machine
dbHost := "localhost"
dbPort := mappedPort.Port()
// Create a connection to the database to check pre-migration state
connStr := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=disable",
dbHost, dbPort, "postgres", "postgres", dbname)
pool, err := pgxpool.New(ctx, connStr)
require.NoError(t, err)
defer pool.Close()
// Check that game table exists (pre-migration state)
gameExists, err := checkTableExists(ctx, pool, "game")
require.NoError(t, err)
assert.True(t, gameExists, "Before migration: game table should exist")
// Check that soundtrack table does NOT exist
soundtrackExists, err := checkTableExists(ctx, pool, "soundtrack")
require.NoError(t, err)
assert.False(t, soundtrackExists, "Before migration: soundtrack table should NOT exist")
t.Log("✓ Pre-migration check passed: game table exists, soundtrack table does not")
// ============================================
// START APPLICATION (triggers migrations)
// ============================================
t.Log("Starting application...")
// Set environment variables for the server
// The application needs to connect to the container's mapped port
os.Setenv("DB_HOST", dbHost)
os.Setenv("DB_PORT", dbPort)
os.Setenv("DB_USERNAME", "postgres")
os.Setenv("DB_PASSWORD", "postgres")
os.Setenv("DB_NAME", dbname)
os.Setenv("MUSIC_PATH", "./testMusic")
os.Setenv("CHARACTERS_PATH", "./testCharacters")
// Use different ports for each test to allow for sequential execution
// and to make port detection reliable
portMap := map[string]string{
"music_server_ref_test": "8082",
"music_server_test_dump_test": "8083",
"music_server_prod_test": "8084",
}
appPort := portMap[dbname]
if appPort == "" {
appPort = "8085" // fallback
}
os.Setenv("PORT", appPort)
os.Setenv("DISABLE_SESSION_AUTH", "true")
os.Setenv("LOG_LEVEL", "error")
// Create server instance (this runs migrations automatically)
serverInstance := NewServerInstance()
httpServer := serverInstance.HTTPServer()
// Start HTTP server in background
go func() {
if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
t.Logf("Server error: %v", err)
}
}()
defer func() {
t.Log("Stopping HTTP server...")
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
httpServer.Shutdown(ctx)
}()
// Wait for server to start by polling /health endpoint
// Use the fixed port we configured for the application
healthURL := fmt.Sprintf("http://localhost:%s/health", appPort)
maxAttempts := 30
for attempt := 1; attempt <= maxAttempts; attempt++ {
resp, err := http.Get(healthURL)
if err == nil {
defer resp.Body.Close()
if resp.StatusCode == http.StatusOK {
var healthData map[string]string
json.NewDecoder(resp.Body).Decode(&healthData)
if healthData["status"] == "up" {
t.Logf("Server is healthy on %s after %d attempts", healthURL, attempt)
break
}
}
}
if attempt == maxAttempts {
require.Fail(t, "Server did not become healthy within timeout")
}
time.Sleep(500 * time.Millisecond)
}
// ============================================
// POST-MIGRATION CHECK
// ============================================
t.Log("Running post-migration checks...")
// Check that game table no longer exists
gameExistsAfter, err := checkTableExists(ctx, pool, "game")
require.NoError(t, err)
assert.False(t, gameExistsAfter, "After migration: game table should NOT exist")
// Check that soundtrack table now exists
soundtrackExistsAfter, err := checkTableExists(ctx, pool, "soundtrack")
require.NoError(t, err)
assert.True(t, soundtrackExistsAfter, "After migration: soundtrack table should exist")
// Verify data was preserved
var soundtrackCount int
err = pool.QueryRow(ctx, "SELECT COUNT(*) FROM soundtrack").Scan(&soundtrackCount)
require.NoError(t, err)
assert.Greater(t, soundtrackCount, 0, "Migration should preserve data: soundtrack count should be > 0")
t.Logf("✓ Post-migration check passed: game table gone, soundtrack table exists with %d records", soundtrackCount)
// ============================================
// HEALTH CHECK
// ============================================
t.Log("Running health check...")
resp, err := http.Get(healthURL)
require.NoError(t, err)
defer resp.Body.Close()
assert.Equal(t, http.StatusOK, resp.StatusCode)
var healthData map[string]string
err = json.NewDecoder(resp.Body).Decode(&healthData)
require.NoError(t, err)
assert.Equal(t, "up", healthData["status"])
t.Log("✓ Health check passed")
t.Log("✓ Test completed successfully!")
}
+16 -11
View File
@@ -12,6 +12,7 @@ import (
"music-server/internal/db"
"music-server/internal/db/repository"
"github.com/google/uuid"
"github.com/labstack/echo/v5"
"github.com/stretchr/testify/require"
)
@@ -122,33 +123,37 @@ func insertTestData(t *testing.T) {
SoundtrackName: st.name,
Path: st.path,
Hash: "test-hash-" + st.name,
Uuid: uuid.New(),
})
require.NoError(t, err, "Failed to insert soundtrack: %s", st.name)
}
// Get soundtrack IDs
// Get soundtrack IDs and UUIDs
soundtrackIDs, err := queries.FindAllSoundtracks(ctx)
require.NoError(t, err)
require.GreaterOrEqual(t, len(soundtrackIDs), 5)
// Insert songs for each soundtrack
songData := []struct {
soundtrackID int32
songs []string
soundtrackID int32
soundtrackUuid uuid.UUID
songs []string
}{
{soundtrackIDs[0].ID, []string{"Song A", "Song B"}},
{soundtrackIDs[1].ID, []string{"Song C", "Song D"}},
{soundtrackIDs[2].ID, []string{"Song E"}},
{soundtrackIDs[3].ID, []string{"Song F", "Song G", "Song H"}},
{soundtrackIDs[4].ID, []string{"Song I"}},
{soundtrackIDs[0].ID, soundtrackIDs[0].Uuid, []string{"Song A", "Song B"}},
{soundtrackIDs[1].ID, soundtrackIDs[1].Uuid, []string{"Song C", "Song D"}},
{soundtrackIDs[2].ID, soundtrackIDs[2].Uuid, []string{"Song E"}},
{soundtrackIDs[3].ID, soundtrackIDs[3].Uuid, []string{"Song F", "Song G", "Song H"}},
{soundtrackIDs[4].ID, soundtrackIDs[4].Uuid, []string{"Song I"}},
}
for _, sd := range songData {
for _, songName := range sd.songs {
err := queries.AddSong(ctx, repository.AddSongParams{
SoundtrackID: sd.soundtrackID,
SongName: songName,
Path: "/path/to/" + songName + ".mp3",
SoundtrackID: sd.soundtrackID,
SongName: songName,
Path: "/path/to/" + songName + ".mp3",
Uuid: uuid.New(),
SoundtrackUuid: sd.soundtrackUuid,
})
require.NoError(t, err, "Failed to insert song: %s", songName)
}
+32 -8
View File
@@ -4,28 +4,30 @@ import (
"crypto/rand"
"encoding/base64"
"net/http"
"os"
"strings"
"time"
"music-server/internal/db/repository"
"music-server/internal/logging"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/labstack/echo/v5"
"go.uber.org/zap"
)
// TokenRequest represents a request to generate a new token
type TokenRequest struct {
Password string `json:"password"` // Required: API password
ClientType string `json:"client_type"` // Optional: "web", "mobile", "api"
}
// TokenResponse represents the response with a new token
type TokenResponse struct {
Token string `json:"token"`
ExpiresAt time.Time `json:"expires_at"`
ClientType string `json:"client_type"`
Token string `json:"token"`
ExpiresAt time.Time `json:"expires_at"`
ClientType string `json:"client_type"`
}
// TokenHandler contains the database pool for token operations
@@ -53,13 +55,14 @@ func (h *TokenHandler) generateToken() (string, error) {
// POST /api/v1/token
//
// @Summary Create session token
// @Description Returns a new session token for API access
// @Description Returns a new session token for API access. Requires valid API password.
// @Tags auth
// @Accept json
// @Produce json
// @Param request body TokenRequest true "Client type"
// @Param request body TokenRequest true "Password and client type"
// @Success 200 {object} TokenResponse
// @Failure 400 {object} map[string]string
// @Failure 401 {object} map[string]string
// @Failure 500 {object} map[string]string
// @Router /api/v1/token [post]
func (h *TokenHandler) CreateTokenHandler(c *echo.Context) error {
@@ -68,6 +71,27 @@ func (h *TokenHandler) CreateTokenHandler(c *echo.Context) error {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid request body"})
}
// Check if session auth is disabled
disableSessionAuth := os.Getenv("DISABLE_SESSION_AUTH") == "true"
apiPassword := os.Getenv("API_PASSWORD")
// Password validation logic:
// - If session auth is disabled, skip validation
// - If API_PASSWORD is empty, allow any password (open access mode)
// - If API_PASSWORD is set, require exact password match
if !disableSessionAuth {
if apiPassword != "" {
// API password is set - require exact match
if req.Password != apiPassword {
logging.GetLogger().Warn("Invalid password attempt for token creation",
zap.String("ip", c.RealIP()),
zap.String("user_agent", c.Request().UserAgent()))
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "Invalid password"})
}
}
// If API_PASSWORD is empty, allow any password through (open access mode)
}
if req.ClientType == "" {
req.ClientType = "web"
}
@@ -98,8 +122,8 @@ func (h *TokenHandler) CreateTokenHandler(c *echo.Context) error {
}
response := TokenResponse{
Token: session.Token,
ExpiresAt: session.ExpiresAt.Time,
Token: session.Token,
ExpiresAt: session.ExpiresAt.Time,
ClientType: *session.ClientType,
}