Files
xdrop/apps/api/cmd/api/app_test.go
T

516 lines
13 KiB
Go

package main
import (
"context"
"errors"
"io"
"log/slog"
nethttp "net/http"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/stretchr/testify/require"
"github.com/xdrop/monorepo/internal/config"
"github.com/xdrop/monorepo/internal/ratelimit"
"github.com/xdrop/monorepo/internal/repo"
"github.com/xdrop/monorepo/internal/service"
"github.com/xdrop/monorepo/internal/storage"
)
func TestRunMainHandlesLoadConfigErrors(t *testing.T) {
t.Parallel()
err := runMain(context.Background(), func() {}, slog.New(slog.NewTextHandler(io.Discard, nil)), appHooks{
loadConfig: func() (config.Config, error) {
return config.Config{}, errors.New("broken config")
},
buildRuntime: func(context.Context, config.Config) (*appRuntime, error) {
t.Fatal("buildRuntime should not be called")
return nil, nil
},
})
require.ErrorContains(t, err, "load config")
}
func TestRunMainBuildsRuntimeRunsServerAndClosesRuntime(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithCancel(context.Background())
cancel()
cfg := config.Config{Addr: ":8080"}
server := newFakeServer()
closed := false
cleanupStarted := make(chan struct{}, 1)
err := runMain(ctx, cancel, slog.New(slog.NewTextHandler(io.Discard, nil)), appHooks{
loadConfig: func() (config.Config, error) {
return cfg, nil
},
buildRuntime: func(context.Context, config.Config) (*appRuntime, error) {
return &appRuntime{
service: nil,
close: func() {
closed = true
},
}, nil
},
newServer: func(config.Config, *slog.Logger, *service.Service) httpServer {
return server
},
startCleanup: func(context.Context, *slog.Logger, time.Duration, *service.Service) {
cleanupStarted <- struct{}{}
},
})
require.NoError(t, err)
require.True(t, closed)
require.True(t, server.shutdownCalled)
select {
case <-cleanupStarted:
case <-time.After(time.Second):
t.Fatal("expected cleanup loop to start")
}
}
func TestDefaultHooksAndHelpers(t *testing.T) {
t.Parallel()
t.Run("app error unwrap", func(t *testing.T) {
t.Parallel()
cause := errors.New("root cause")
err := &appError{message: "outer", err: cause}
require.ErrorIs(t, err, cause)
})
t.Run("default hooks are wired", func(t *testing.T) {
t.Parallel()
builder := defaultBuildHooks()
require.NotNil(t, builder.openDB)
require.NotNil(t, builder.waitForDB)
require.NotNil(t, builder.runMigrations)
require.NotNil(t, builder.openStorage)
require.NotNil(t, builder.openRedis)
require.NotNil(t, builder.newLimiter)
require.NotNil(t, builder.newRepository)
hooks := defaultAppHooks()
require.NotNil(t, hooks.loadConfig)
require.NotNil(t, hooks.buildRuntime)
require.NotNil(t, hooks.newServer)
require.NotNil(t, hooks.startCleanup)
require.NoError(t, waitForPostgres(context.Background(), &fakeDB{}))
require.ErrorContains(t, runPostgresMigrations(context.Background(), &fakeDB{}), "unexpected db handle type")
pool, err := pgxpool.New(context.Background(), "postgres://xdrop:xdrop@127.0.0.1:1/xdrop?sslmode=disable&connect_timeout=1")
require.NoError(t, err)
t.Cleanup(pool.Close)
require.Error(t, runPostgresMigrations(context.Background(), &pgxPoolHandle{Pool: pool}))
store, err := openObjectStorage(context.Background(), config.Config{
S3Endpoint: "http://localhost:9000",
S3PublicEndpoint: "http://localhost:9000",
S3Region: "us-east-1",
S3Bucket: "xdrop",
S3AccessKey: "minioadmin",
S3SecretKey: "minioadmin",
})
require.NoError(t, err)
require.NotNil(t, store)
redisClient := openRedisClient(config.Config{RedisAddr: "localhost:6379"})
require.NotNil(t, redisClient)
require.NotNil(t, newRedisLimiter(redisClient))
require.Nil(t, newRedisLimiter(&fakeRedis{}))
require.NotNil(t, newPostgresRepository(&pgxPoolHandle{}))
require.Nil(t, newPostgresRepository(&fakeDB{}))
db, err := openPostgresDB(context.Background(), "postgres://xdrop:xdrop@localhost:5432/xdrop?sslmode=disable")
require.NoError(t, err)
db.Close()
_, err = buildDefaultRuntime(context.Background(), config.Config{DatabaseURL: "postgres://%zz"})
require.Error(t, err)
})
t.Run("logger and server helpers", func(t *testing.T) {
t.Parallel()
logger := newLogger(io.Discard)
require.NotNil(t, logger)
server := newHTTPServer(config.Config{Addr: ":8080"}, logger, nil)
httpServer, ok := server.(*nethttp.Server)
require.True(t, ok)
require.Equal(t, ":8080", httpServer.Addr)
require.Equal(t, 10*time.Second, httpServer.ReadHeaderTimeout)
require.NotNil(t, httpServer.Handler)
})
}
func TestBuildRuntimeHandlesFailuresAndSuccess(t *testing.T) {
t.Parallel()
baseConfig := config.Config{
DatabaseURL: "postgres://example.test/xdrop",
}
t.Run("connect postgres failure", func(t *testing.T) {
t.Parallel()
_, err := buildRuntime(context.Background(), baseConfig, buildHooks{
openDB: func(context.Context, string) (dbHandle, error) {
return nil, errors.New("dial failed")
},
})
require.ErrorContains(t, err, "connect postgres")
})
t.Run("ping failure closes database", func(t *testing.T) {
t.Parallel()
db := &fakeDB{pingErr: errors.New("ping failed")}
_, err := buildRuntime(context.Background(), baseConfig, buildHooks{
openDB: func(context.Context, string) (dbHandle, error) {
return db, nil
},
waitForDB: func(context.Context, dbHandle) error {
return db.Ping(context.Background())
},
})
require.ErrorContains(t, err, "ping postgres")
require.True(t, db.closed)
})
t.Run("migration and storage failures close database", func(t *testing.T) {
t.Parallel()
t.Run("migration", func(t *testing.T) {
db := &fakeDB{}
_, err := buildRuntime(context.Background(), baseConfig, buildHooks{
openDB: func(context.Context, string) (dbHandle, error) {
return db, nil
},
waitForDB: func(context.Context, dbHandle) error {
return nil
},
runMigrations: func(context.Context, dbHandle) error {
return errors.New("migration failed")
},
})
require.ErrorContains(t, err, "run migrations")
require.True(t, db.closed)
})
t.Run("ensure bucket", func(t *testing.T) {
db := &fakeDB{}
store := &fakeObjectStorage{ensureBucketErr: errors.New("bucket failed")}
_, err := buildRuntime(context.Background(), baseConfig, buildHooks{
openDB: func(context.Context, string) (dbHandle, error) {
return db, nil
},
waitForDB: func(context.Context, dbHandle) error {
return nil
},
runMigrations: func(context.Context, dbHandle) error {
return nil
},
openStorage: func(context.Context, config.Config) (storage.ObjectStorage, error) {
return store, nil
},
})
require.ErrorContains(t, err, "ensure bucket")
require.True(t, db.closed)
require.True(t, store.ensureBucketCalled)
})
t.Run("open storage", func(t *testing.T) {
db := &fakeDB{}
_, err := buildRuntime(context.Background(), baseConfig, buildHooks{
openDB: func(context.Context, string) (dbHandle, error) {
return db, nil
},
waitForDB: func(context.Context, dbHandle) error {
return nil
},
runMigrations: func(context.Context, dbHandle) error {
return nil
},
openStorage: func(context.Context, config.Config) (storage.ObjectStorage, error) {
return nil, errors.New("storage failed")
},
})
require.ErrorContains(t, err, "init object storage")
require.True(t, db.closed)
})
})
t.Run("nil waitForDB falls back to Ping", func(t *testing.T) {
t.Parallel()
db := &fakeDB{}
redisClient := &fakeRedis{}
store := &fakeObjectStorage{}
runtime, err := buildRuntime(context.Background(), baseConfig, buildHooks{
openDB: func(context.Context, string) (dbHandle, error) {
return db, nil
},
runMigrations: func(context.Context, dbHandle) error {
return nil
},
openStorage: func(context.Context, config.Config) (storage.ObjectStorage, error) {
return store, nil
},
openRedis: func(config.Config) redisHandle {
return redisClient
},
newLimiter: func(redisHandle) ratelimit.Limiter {
return nil
},
newRepository: func(dbHandle) repo.Repository {
return nil
},
})
require.NoError(t, err)
require.Equal(t, 1, db.pingCalls)
runtime.close()
})
t.Run("success closes redis and database", func(t *testing.T) {
t.Parallel()
db := &fakeDB{}
redisClient := &fakeRedis{}
store := &fakeObjectStorage{}
runtime, err := buildRuntime(context.Background(), baseConfig, buildHooks{
openDB: func(context.Context, string) (dbHandle, error) {
return db, nil
},
waitForDB: func(context.Context, dbHandle) error {
return nil
},
runMigrations: func(context.Context, dbHandle) error {
return nil
},
openStorage: func(context.Context, config.Config) (storage.ObjectStorage, error) {
return store, nil
},
openRedis: func(config.Config) redisHandle {
return redisClient
},
newLimiter: func(redisHandle) ratelimit.Limiter {
return nil
},
newRepository: func(dbHandle) repo.Repository {
return nil
},
})
require.NoError(t, err)
require.NotNil(t, runtime)
require.True(t, store.ensureBucketCalled)
require.False(t, db.closed)
require.False(t, redisClient.closed)
runtime.close()
require.True(t, db.closed)
require.True(t, redisClient.closed)
})
}
func TestRunServerStartsCleanupAndShutsDownOnCancel(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithCancel(context.Background())
server := newFakeServer()
cleanupStarted := make(chan struct{}, 1)
done := make(chan struct{})
go func() {
runServer(
ctx,
cancel,
slog.New(slog.NewTextHandler(io.Discard, nil)),
config.Config{Addr: ":8080", CleanupInterval: time.Second},
nil,
func(config.Config, *slog.Logger, *service.Service) httpServer { return server },
func(context.Context, *slog.Logger, time.Duration, *service.Service) { cleanupStarted <- struct{}{} },
)
close(done)
}()
<-server.listenStarted
cancel()
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("runServer did not return after cancellation")
}
require.True(t, server.shutdownCalled)
select {
case <-cleanupStarted:
case <-time.After(time.Second):
t.Fatal("expected cleanup loop to start")
}
}
func TestRunServerCancelsContextWhenListenFails(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithCancel(context.Background())
server := &fakeServer{listenErr: errors.New("bind failed"), listenStarted: make(chan struct{})}
done := make(chan struct{})
go func() {
runServer(
ctx,
cancel,
slog.New(slog.NewTextHandler(io.Discard, nil)),
config.Config{Addr: ":8080"},
nil,
func(config.Config, *slog.Logger, *service.Service) httpServer { return server },
nil,
)
close(done)
}()
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("runServer did not return after listen failure")
}
require.ErrorIs(t, ctx.Err(), context.Canceled)
require.True(t, server.shutdownCalled)
}
func TestRunServerContinuesAfterShutdownError(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithCancel(context.Background())
server := newFakeServer()
server.shutdownErr = errors.New("shutdown failed")
done := make(chan struct{})
go func() {
runServer(
ctx,
cancel,
slog.New(slog.NewTextHandler(io.Discard, nil)),
config.Config{Addr: ":8080"},
nil,
func(config.Config, *slog.Logger, *service.Service) httpServer { return server },
nil,
)
close(done)
}()
<-server.listenStarted
cancel()
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("runServer did not return after shutdown failure")
}
require.True(t, server.shutdownCalled)
}
type fakeDB struct {
pingErr error
pingCalls int
closed bool
}
func (d *fakeDB) Ping(context.Context) error {
d.pingCalls++
return d.pingErr
}
func (d *fakeDB) Close() {
d.closed = true
}
type fakeRedis struct {
closed bool
}
func (r *fakeRedis) Close() error {
r.closed = true
return nil
}
type fakeObjectStorage struct {
ensureBucketErr error
ensureBucketCalled bool
}
func (s *fakeObjectStorage) PresignUpload(context.Context, string, time.Duration) (string, error) {
return "", nil
}
func (s *fakeObjectStorage) PresignDownload(context.Context, string, time.Duration) (string, error) {
return "", nil
}
func (s *fakeObjectStorage) PutObject(context.Context, string, []byte, string) error {
return nil
}
func (s *fakeObjectStorage) DeletePrefix(context.Context, string) error {
return nil
}
func (s *fakeObjectStorage) EnsureBucket(context.Context) error {
s.ensureBucketCalled = true
return s.ensureBucketErr
}
type fakeServer struct {
listenErr error
shutdownErr error
shutdownCalled bool
listenStarted chan struct{}
stop chan struct{}
stopClosed bool
}
func newFakeServer() *fakeServer {
return &fakeServer{
listenStarted: make(chan struct{}),
stop: make(chan struct{}),
}
}
func (s *fakeServer) ListenAndServe() error {
if s.listenStarted != nil {
close(s.listenStarted)
s.listenStarted = nil
}
if s.listenErr != nil {
return s.listenErr
}
<-s.stop
return nethttp.ErrServerClosed
}
func (s *fakeServer) Shutdown(context.Context) error {
s.shutdownCalled = true
if s.stop != nil && !s.stopClosed {
close(s.stop)
s.stopClosed = true
}
return s.shutdownErr
}