Files
lijiaoqiao/supply-api/internal/app/runtime_test.go

660 lines
19 KiB
Go

package app
import (
"context"
"encoding/json"
"errors"
"strings"
"testing"
"time"
"lijiaoqiao/supply-api/internal/audit"
"lijiaoqiao/supply-api/internal/cache"
"lijiaoqiao/supply-api/internal/config"
"lijiaoqiao/supply-api/internal/domain"
"lijiaoqiao/supply-api/internal/messaging"
"lijiaoqiao/supply-api/internal/repository"
)
type captureLogger struct {
infoMessages []string
warnMessages []string
errorMessages []string
}
func (l *captureLogger) Debug(string, ...map[string]interface{}) {}
func (l *captureLogger) Info(msg string, _ ...map[string]interface{}) {
l.infoMessages = append(l.infoMessages, msg)
}
func (l *captureLogger) Warn(msg string, _ ...map[string]interface{}) {
l.warnMessages = append(l.warnMessages, msg)
}
func (l *captureLogger) Error(msg string, _ ...map[string]interface{}) {
l.errorMessages = append(l.errorMessages, msg)
}
func (l *captureLogger) Fatal(msg string, _ ...map[string]interface{}) {
l.errorMessages = append(l.errorMessages, msg)
}
func TestBuildRuntime_ProdRequiresDatabase(t *testing.T) {
_, err := buildRuntimeWithFactory(RuntimeOptions{
Env: "prod",
Config: testRuntimeConfig(),
Logger: testLogger{},
InitContext: context.Background(),
Now: func() time.Time {
return time.Unix(1712800000, 0).UTC()
},
}, runtimeFactory{
newDB: func(context.Context, config.DatabaseConfig) (*repository.DB, error) {
return nil, errors.New("db down")
},
newRedisCache: func(config.RedisConfig) (*cache.RedisCache, error) {
return nil, nil
},
})
if err == nil {
t.Fatal("expected prod runtime build to reject database outage")
}
if !strings.Contains(err.Error(), "database unavailable") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestBuildStoreBundle_UsesInMemoryStoresWithoutDatabase(t *testing.T) {
bundle := buildStoreBundle(nil, testLogger{})
if bundle.accountStore == nil {
t.Fatal("expected account store")
}
if bundle.packageStore == nil {
t.Fatal("expected package store")
}
if bundle.settlementStore == nil {
t.Fatal("expected settlement store")
}
if bundle.earningStore == nil {
t.Fatal("expected earning store")
}
if bundle.auditStore == nil {
t.Fatal("expected audit store")
}
if bundle.alertService == nil {
t.Fatal("expected alert service")
}
if bundle.tokenStatusRepo != nil {
t.Fatal("expected nil token status repo without database")
}
if bundle.idempotencyRepo != nil {
t.Fatal("expected nil idempotency repo without database")
}
}
func TestBuildMemoryStoreBundle_DisablesDatabaseOnlyDependencies(t *testing.T) {
bundle := buildMemoryStoreBundle()
if bundle.accountStore == nil {
t.Fatal("expected account store")
}
if bundle.packageStore == nil {
t.Fatal("expected package store")
}
if bundle.settlementStore == nil {
t.Fatal("expected settlement store")
}
if bundle.earningStore == nil {
t.Fatal("expected earning store")
}
if bundle.auditStore == nil {
t.Fatal("expected audit store")
}
if bundle.alertService == nil {
t.Fatal("expected alert service")
}
if bundle.tokenStatusRepo != nil {
t.Fatal("expected nil token status repo")
}
if bundle.idempotencyRepo != nil {
t.Fatal("expected nil idempotency repo")
}
}
func TestBuildStoreBundle_UsesDatabaseBackedStoresWithDatabase(t *testing.T) {
bundle := buildStoreBundle(&repository.DB{}, testLogger{})
if bundle.accountStore == nil {
t.Fatal("expected account store")
}
if bundle.packageStore == nil {
t.Fatal("expected package store")
}
if bundle.settlementStore == nil {
t.Fatal("expected settlement store")
}
if bundle.earningStore == nil {
t.Fatal("expected earning store")
}
if bundle.auditStore == nil {
t.Fatal("expected audit store")
}
if bundle.alertService == nil {
t.Fatal("expected alert service")
}
if bundle.tokenStatusRepo == nil {
t.Fatal("expected token status repo with database")
}
if bundle.idempotencyRepo == nil {
t.Fatal("expected idempotency repo with database")
}
}
func TestBuildDBStoreBundle_InitializesDatabaseOnlyDependencies(t *testing.T) {
bundle := buildDBStoreBundle(&repository.DB{})
if bundle.accountStore == nil {
t.Fatal("expected account store")
}
if bundle.packageStore == nil {
t.Fatal("expected package store")
}
if bundle.settlementStore == nil {
t.Fatal("expected settlement store")
}
if bundle.earningStore == nil {
t.Fatal("expected earning store")
}
if bundle.auditStore == nil {
t.Fatal("expected audit store")
}
if bundle.alertService == nil {
t.Fatal("expected alert service")
}
if bundle.tokenStatusRepo == nil {
t.Fatal("expected token status repo")
}
if bundle.idempotencyRepo == nil {
t.Fatal("expected idempotency repo")
}
}
func TestBuildSecurityBundle_UsesMemoryTokenBackendWithoutRepository(t *testing.T) {
security := buildSecurityBundle("dev", testRuntimeConfig(), testLogger{}, audit.NewMemoryAuditStore(), nil, nil)
if security.authMiddleware == nil {
t.Fatal("expected auth middleware")
}
if security.revocationSubscriber != nil {
t.Fatal("expected nil revocation subscriber without token repository")
}
}
func TestResolveEnv_RejectsUnsupportedValue(t *testing.T) {
_, err := ResolveEnv("qa")
if err == nil {
t.Fatal("expected unsupported env to fail")
}
if !strings.Contains(err.Error(), "unsupported env") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestBuildRuntime_RejectsUnsupportedEnv(t *testing.T) {
_, err := buildRuntimeWithFactory(RuntimeOptions{
Env: "qa",
Config: testRuntimeConfig(),
Logger: testLogger{},
InitContext: context.Background(),
}, runtimeFactory{
newDB: func(context.Context, config.DatabaseConfig) (*repository.DB, error) {
return nil, errors.New("db down")
},
newRedisCache: func(config.RedisConfig) (*cache.RedisCache, error) {
return nil, nil
},
})
if err == nil {
t.Fatal("expected unsupported env to fail")
}
if !strings.Contains(err.Error(), "unsupported env") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestBuildRuntime_DevFallsBackToInMemoryDependencies(t *testing.T) {
runtime, err := buildRuntimeWithFactory(RuntimeOptions{
Env: "dev",
Config: testRuntimeConfig(),
Logger: testLogger{},
InitContext: context.Background(),
Now: func() time.Time {
return time.Unix(1712800000, 0).UTC()
},
}, runtimeFactory{
newDB: func(context.Context, config.DatabaseConfig) (*repository.DB, error) {
return nil, errors.New("db down")
},
newRedisCache: func(config.RedisConfig) (*cache.RedisCache, error) {
return nil, errors.New("redis down")
},
})
if err != nil {
t.Fatalf("expected dev runtime to fall back to in-memory dependencies, got %v", err)
}
if runtime == nil {
t.Fatal("expected runtime")
}
if runtime.db != nil {
t.Fatal("expected nil db after dev fallback")
}
if runtime.redisCache != nil {
t.Fatal("expected nil redis cache after dev fallback")
}
if runtime.supplyAPI == nil || runtime.alertAPI == nil {
t.Fatal("expected apis to be initialized")
}
if runtime.authMiddleware == nil {
t.Fatal("expected auth middleware to be initialized")
}
if runtime.rateLimitConfig == nil {
t.Fatal("expected rate limit config to be initialized")
}
}
func TestBuildRuntime_NormalizesServerConfigDefaults(t *testing.T) {
cfg := testRuntimeConfig()
cfg.Server = config.ServerConfig{}
runtime, err := buildRuntimeWithFactory(RuntimeOptions{
Env: "dev",
Config: cfg,
Logger: testLogger{},
InitContext: context.Background(),
Now: func() time.Time {
return time.Unix(1712800000, 0).UTC()
},
}, runtimeFactory{
newDB: func(context.Context, config.DatabaseConfig) (*repository.DB, error) {
return nil, errors.New("db down")
},
newRedisCache: func(config.RedisConfig) (*cache.RedisCache, error) {
return nil, errors.New("redis down")
},
})
if err != nil {
t.Fatalf("expected runtime build to succeed, got %v", err)
}
if runtime.serverConfig.Addr != ":18082" {
t.Fatalf("unexpected addr: %s", runtime.serverConfig.Addr)
}
if runtime.ShutdownTimeout() != 5*time.Second {
t.Fatalf("unexpected shutdown timeout: %s", runtime.ShutdownTimeout())
}
}
func TestBuildRuntime_SeedsDefaultTuning(t *testing.T) {
runtime, err := buildRuntimeWithFactory(RuntimeOptions{
Env: "dev",
Config: testRuntimeConfig(),
Logger: testLogger{},
InitContext: context.Background(),
Now: func() time.Time {
return time.Unix(1712800000, 0).UTC()
},
}, runtimeFactory{
newDB: func(context.Context, config.DatabaseConfig) (*repository.DB, error) {
return nil, errors.New("db down")
},
newRedisCache: func(config.RedisConfig) (*cache.RedisCache, error) {
return nil, errors.New("redis down")
},
})
if err != nil {
t.Fatalf("expected runtime build to succeed, got %v", err)
}
if runtime.tuning.outboxStreamName != "supply:outbox:stream" {
t.Fatalf("unexpected outbox stream: %s", runtime.tuning.outboxStreamName)
}
if runtime.tuning.outboxConsumerGroup != "outbox-processor" {
t.Fatalf("unexpected outbox group: %s", runtime.tuning.outboxConsumerGroup)
}
if runtime.tuning.idempotencyTTL != 24*time.Hour {
t.Fatalf("unexpected idempotency ttl: %s", runtime.tuning.idempotencyTTL)
}
if runtime.tuning.partitionMaintenanceInterval != time.Hour {
t.Fatalf("unexpected partition maintenance interval: %s", runtime.tuning.partitionMaintenanceInterval)
}
if runtime.tuning.compensationCheckInterval != 5*time.Minute {
t.Fatalf("unexpected compensation interval: %s", runtime.tuning.compensationCheckInterval)
}
}
func TestBuildRuntime_DevFallbackLogsWarnings(t *testing.T) {
logger := &captureLogger{}
_, err := buildRuntimeWithFactory(RuntimeOptions{
Env: "dev",
Config: testRuntimeConfig(),
Logger: logger,
InitContext: context.Background(),
}, runtimeFactory{
newDB: func(context.Context, config.DatabaseConfig) (*repository.DB, error) {
return nil, errors.New("db down")
},
newRedisCache: func(config.RedisConfig) (*cache.RedisCache, error) {
return nil, errors.New("redis down")
},
})
if err != nil {
t.Fatalf("expected runtime build to succeed, got %v", err)
}
if len(logger.warnMessages) == 0 {
t.Fatal("expected warning logs during dev fallback")
}
if len(logger.infoMessages) == 0 {
t.Fatal("expected info logs during successful in-memory runtime initialization")
}
}
func TestRuntime_StartBackgroundWorkers_WithoutDatabaseIsNoop(t *testing.T) {
var outboxRepoCalled bool
err := startBackgroundWorkersWithFactory(context.Background(), context.Background(), &Runtime{
env: "dev",
logger: testLogger{},
}, backgroundFactory{
newOutboxRepository: func(*repository.DB) outboxRepository {
outboxRepoCalled = true
return stubOutboxRepository{}
},
})
if err != nil {
t.Fatalf("expected nil error when database is unavailable, got %v", err)
}
if outboxRepoCalled {
t.Fatal("expected background workers to skip db-backed startup when db is nil")
}
}
func TestRuntime_StartBackgroundWorkers_ProdRequiresOutboxBroker(t *testing.T) {
err := startBackgroundWorkersWithFactory(context.Background(), context.Background(), &Runtime{
env: "prod",
logger: testLogger{},
db: &repository.DB{},
}, backgroundFactory{
newOutboxRepository: func(*repository.DB) outboxRepository {
return stubOutboxRepository{}
},
newMessageBroker: func(*cache.RedisCache) messaging.MessageBroker {
return nil
},
})
if err == nil {
t.Fatal("expected missing outbox broker to fail in prod")
}
if !strings.Contains(err.Error(), "outbox message broker unavailable") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestStartOutboxProcessor_ProdRequiresBroker(t *testing.T) {
err := startOutboxProcessor(context.Background(), &Runtime{
env: "prod",
logger: testLogger{},
db: &repository.DB{},
tuning: defaultRuntimeTuning(),
}, backgroundFactory{
newOutboxRepository: func(*repository.DB) outboxRepository {
return stubOutboxRepository{}
},
newMessageBroker: func(*cache.RedisCache) messaging.MessageBroker {
return nil
},
})
if err == nil {
t.Fatal("expected missing outbox broker to fail in prod")
}
if !strings.Contains(err.Error(), "outbox message broker unavailable") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestRuntime_StartBackgroundWorkers_UsesDefaultCompensationInterval(t *testing.T) {
var gotInterval time.Duration
err := startBackgroundWorkersWithFactory(context.Background(), context.Background(), &Runtime{
env: "dev",
logger: testLogger{},
db: &repository.DB{},
tuning: defaultRuntimeTuning(),
}, backgroundFactory{
newOutboxRepository: func(*repository.DB) outboxRepository {
return stubOutboxRepository{}
},
newMessageBroker: func(*cache.RedisCache) messaging.MessageBroker {
return stubMessageBroker{}
},
newOutboxRunner: func(outboxRepository, messaging.MessageBroker, messaging.OutboxStats) outboxRunner {
return stubOutboxRunner{}
},
newPartitionManager: func(*repository.DB) partitionManager {
return stubPartitionManager{}
},
newCompensationStore: func(*repository.DB) domain.CompensationStore {
return stubCompensationStore{}
},
newCompensationExecutor: func() domain.OperationExecutor {
return stubOperationExecutor{}
},
newCompensationProcessor: func(
domain.CompensationStore,
domain.OperationExecutor,
domain.CompensationStats,
) compensationWorker {
return stubCompensationWorker{
start: func(_ context.Context, interval time.Duration) {
gotInterval = interval
},
}
},
})
if err != nil {
t.Fatalf("expected background startup to succeed, got %v", err)
}
if gotInterval != 5*time.Minute {
t.Fatalf("unexpected compensation interval: %s", gotInterval)
}
}
func TestStartCompensationWorker_UsesConfiguredInterval(t *testing.T) {
var gotInterval time.Duration
startCompensationWorker(context.Background(), &Runtime{
env: "dev",
logger: testLogger{},
db: &repository.DB{},
tuning: defaultRuntimeTuning(),
}, backgroundFactory{
newCompensationStore: func(*repository.DB) domain.CompensationStore {
return stubCompensationStore{}
},
newCompensationExecutor: func() domain.OperationExecutor {
return stubOperationExecutor{}
},
newCompensationProcessor: func(
domain.CompensationStore,
domain.OperationExecutor,
domain.CompensationStats,
) compensationWorker {
return stubCompensationWorker{
start: func(_ context.Context, interval time.Duration) {
gotInterval = interval
},
}
},
})
if gotInterval != 5*time.Minute {
t.Fatalf("unexpected compensation interval: %s", gotInterval)
}
}
func TestRuntime_StartBackgroundWorkers_DevMissingOutboxBrokerLogsWarning(t *testing.T) {
logger := &captureLogger{}
err := startBackgroundWorkersWithFactory(context.Background(), context.Background(), &Runtime{
env: "dev",
logger: logger,
db: &repository.DB{},
tuning: defaultRuntimeTuning(),
}, backgroundFactory{
newOutboxRepository: func(*repository.DB) outboxRepository {
return stubOutboxRepository{}
},
newMessageBroker: func(*cache.RedisCache) messaging.MessageBroker {
return nil
},
newPartitionManager: func(*repository.DB) partitionManager {
return stubPartitionManager{}
},
newCompensationStore: func(*repository.DB) domain.CompensationStore {
return stubCompensationStore{}
},
newCompensationExecutor: func() domain.OperationExecutor {
return stubOperationExecutor{}
},
newCompensationProcessor: func(
domain.CompensationStore,
domain.OperationExecutor,
domain.CompensationStats,
) compensationWorker {
return stubCompensationWorker{}
},
})
if err != nil {
t.Fatalf("expected background startup to succeed, got %v", err)
}
if len(logger.warnMessages) == 0 {
t.Fatal("expected warning log when outbox broker is unavailable in dev")
}
}
func testRuntimeConfig() *config.Config {
return &config.Config{
Server: config.ServerConfig{
Addr: ":18082",
ReadTimeout: 10 * time.Second,
WriteTimeout: 15 * time.Second,
IdleTimeout: 30 * time.Second,
ShutdownTimeout: 5 * time.Second,
DefaultSupplierID: 1,
StatementBaseURL: "https://statements.example.com",
},
Database: config.DatabaseConfig{
Host: "127.0.0.1",
Port: 5432,
User: "test",
Password: "test",
Database: "supply",
MaxOpenConns: 4,
MaxIdleConns: 2,
ConnMaxLifetime: time.Minute,
ConnMaxIdleTime: time.Minute,
},
Redis: config.RedisConfig{
Host: "127.0.0.1",
Port: 6379,
Password: "",
DB: 0,
PoolSize: 2,
},
Token: config.TokenConfig{
SecretKey: "runtime-test-secret",
Algorithm: "HS256",
Issuer: "runtime-test",
RevocationCacheTTL: 10 * time.Second,
},
Settlement: config.SettlementConfig{
WithdrawEnabled: true,
},
}
}
type stubOutboxRepository struct{}
func (stubOutboxRepository) FetchAndLock(context.Context, int) ([]*repository.OutboxEvent, error) {
return nil, nil
}
func (stubOutboxRepository) MarkCompleted(context.Context, string) error {
return nil
}
func (stubOutboxRepository) MarkFailed(context.Context, string, string, *time.Time) error {
return nil
}
func (stubOutboxRepository) MoveToDeadLetter(context.Context, *repository.OutboxEvent, string) error {
return nil
}
type stubMessageBroker struct{}
func (stubMessageBroker) Publish(context.Context, *repository.OutboxEvent) error {
return nil
}
type stubOutboxRunner struct{}
func (stubOutboxRunner) Start(context.Context) {}
type stubPartitionManager struct{}
func (stubPartitionManager) EnsureFuturePartitions(context.Context) error {
return nil
}
func (stubPartitionManager) DropOldPartitions(context.Context, string) (int, error) {
return 0, nil
}
type stubCompensationStore struct{}
func (stubCompensationStore) Create(context.Context, *domain.BatchCompensation) (int64, error) {
return 0, nil
}
func (stubCompensationStore) GetByBatchID(context.Context, string) ([]*domain.BatchCompensation, error) {
return nil, nil
}
func (stubCompensationStore) GetPending(context.Context) ([]*domain.BatchCompensation, error) {
return nil, nil
}
func (stubCompensationStore) UpdateStatus(context.Context, int64, string) error {
return nil
}
func (stubCompensationStore) Resolve(context.Context, int64, int64, string) error {
return nil
}
func (stubCompensationStore) MarkManualRequired(context.Context, int64, string) error {
return nil
}
type stubOperationExecutor struct{}
func (stubOperationExecutor) Execute(context.Context, string, json.RawMessage) error {
return nil
}
type stubCompensationWorker struct {
start func(context.Context, time.Duration)
}
func (w stubCompensationWorker) StartBackgroundWorker(ctx context.Context, interval time.Duration) context.Context {
if w.start != nil {
w.start(ctx, interval)
}
return ctx
}