#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
Build / build (push) Successful in 56s
This commit is contained in:
1 parent
e86c0f8e04
commit
a8a2602f50
34 files changed
+1819
-1294
No files matched your search
@@ -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
@@ -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
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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!")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user