package app import ( "context" "io" "log/slog" "net" "os" "path/filepath" "testing" "time" "galaxy/mail/internal/config" "github.com/alicebob/miniredis/v2" "github.com/stretchr/testify/require" ) func TestNewRuntimeStartsWithStubMode(t *testing.T) { redisServer := miniredis.RunT(t) templateDir := writeStage6Templates(t) truncateRuntimeMail(t) cfg := runtimeBaseConfig(t, redisServer.Addr()) cfg.Templates.Dir = templateDir cfg.InternalHTTP.Addr = mustFreeAddr(t) runtime, err := NewRuntime(context.Background(), cfg, testLogger()) require.NoError(t, err) require.NoError(t, runtime.Close()) } func TestNewRuntimeRejectsInvalidRedisConfig(t *testing.T) { redisServer := miniredis.RunT(t) templateDir := writeStage6Templates(t) truncateRuntimeMail(t) cfg := runtimeBaseConfig(t, redisServer.Addr()) cfg.Redis.Conn.Password = "" cfg.Templates.Dir = templateDir cfg.InternalHTTP.Addr = mustFreeAddr(t) _, err := NewRuntime(context.Background(), cfg, testLogger()) require.Error(t, err) require.Contains(t, err.Error(), "redis password") } func TestNewRuntimeRejectsUnavailableRedis(t *testing.T) { templateDir := writeStage6Templates(t) cfg := runtimeBaseConfig(t, "127.0.0.1:6399") cfg.Redis.Conn.OperationTimeout = 100 * time.Millisecond cfg.Templates.Dir = templateDir cfg.InternalHTTP.Addr = mustFreeAddr(t) _, err := NewRuntime(context.Background(), cfg, testLogger()) require.Error(t, err) require.Contains(t, err.Error(), "ping redis") } func TestNewRuntimeRejectsMissingTemplateDirectory(t *testing.T) { redisServer := miniredis.RunT(t) truncateRuntimeMail(t) cfg := runtimeBaseConfig(t, redisServer.Addr()) cfg.Templates.Dir = filepath.Join(t.TempDir(), "missing") cfg.InternalHTTP.Addr = mustFreeAddr(t) _, err := NewRuntime(context.Background(), cfg, testLogger()) require.Error(t, err) require.Contains(t, err.Error(), "template catalog") } func TestNewRuntimeRejectsMissingRequiredTemplateFile(t *testing.T) { redisServer := miniredis.RunT(t) rootDir := t.TempDir() require.NoError(t, os.MkdirAll(filepath.Join(rootDir, "auth.login_code", "en"), 0o755)) require.NoError(t, os.WriteFile(filepath.Join(rootDir, "auth.login_code", "en", "subject.tmpl"), []byte("Subject"), 0o644)) truncateRuntimeMail(t) cfg := runtimeBaseConfig(t, redisServer.Addr()) cfg.Templates.Dir = rootDir cfg.InternalHTTP.Addr = mustFreeAddr(t) _, err := NewRuntime(context.Background(), cfg, testLogger()) require.Error(t, err) require.Contains(t, err.Error(), "text.tmpl") } func TestNewRuntimeRejectsBrokenTemplateCatalog(t *testing.T) { redisServer := miniredis.RunT(t) rootDir := t.TempDir() require.NoError(t, os.MkdirAll(filepath.Join(rootDir, "auth.login_code", "en"), 0o755)) require.NoError(t, os.WriteFile(filepath.Join(rootDir, "auth.login_code", "en", "subject.tmpl"), []byte("Your login code"), 0o644)) require.NoError(t, os.WriteFile(filepath.Join(rootDir, "auth.login_code", "en", "text.tmpl"), []byte("Code: {{.code}}"), 0o644)) require.NoError(t, os.MkdirAll(filepath.Join(rootDir, "game.turn.ready", "en"), 0o755)) require.NoError(t, os.WriteFile(filepath.Join(rootDir, "game.turn.ready", "en", "subject.tmpl"), []byte("{{if .turn_number}"), 0o644)) require.NoError(t, os.WriteFile(filepath.Join(rootDir, "game.turn.ready", "en", "text.tmpl"), []byte("Turn ready"), 0o644)) truncateRuntimeMail(t) cfg := runtimeBaseConfig(t, redisServer.Addr()) cfg.Templates.Dir = rootDir cfg.InternalHTTP.Addr = mustFreeAddr(t) _, err := NewRuntime(context.Background(), cfg, testLogger()) require.Error(t, err) require.Contains(t, err.Error(), "template parse failed") } func TestRuntimeRunStopsOnContextCancellation(t *testing.T) { redisServer := miniredis.RunT(t) templateDir := writeStage6Templates(t) truncateRuntimeMail(t) cfg := runtimeBaseConfig(t, redisServer.Addr()) cfg.Templates.Dir = templateDir cfg.InternalHTTP.Addr = mustFreeAddr(t) cfg.ShutdownTimeout = time.Second runtime, err := NewRuntime(context.Background(), cfg, testLogger()) require.NoError(t, err) defer func() { require.NoError(t, runtime.Close()) }() runCtx, cancel := context.WithCancel(context.Background()) done := make(chan error, 1) go func() { done <- runtime.Run(runCtx) }() time.Sleep(100 * time.Millisecond) cancel() require.Eventually(t, func() bool { select { case err := <-done: return err == nil default: return false } }, 5*time.Second, 10*time.Millisecond) } func writeStage6Templates(t *testing.T) string { t.Helper() rootDir := t.TempDir() templateDir := filepath.Join(rootDir, "auth.login_code", "en") require.NoError(t, os.MkdirAll(templateDir, 0o755)) require.NoError(t, os.WriteFile(filepath.Join(templateDir, "subject.tmpl"), []byte("Your login code"), 0o644)) require.NoError(t, os.WriteFile(filepath.Join(templateDir, "text.tmpl"), []byte("Code: {{.code}}"), 0o644)) return rootDir } func testLogger() *slog.Logger { return slog.New(slog.NewJSONHandler(io.Discard, nil)) } func mustFreeAddr(t *testing.T) string { t.Helper() listener, err := net.Listen("tcp", "127.0.0.1:0") require.NoError(t, err) defer func() { require.NoError(t, listener.Close()) }() return listener.Addr().String() } var _ = config.SMTPModeStub // keep config import even when no test uses it directly