mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
* feat(server): supervisor detection and server.restart_mode config key RunningUnderSupervisor detects systemd (INVOCATION_ID) and, best-effort, NSSM (NSSM_SERVICE_NAME — 2.24 does not set it, so NSSM deployments set the mode explicitly). server.restart_mode (auto|spawn|supervised, default auto, env OWNCORD_SERVER_RESTART_MODE) selects how a self-restart hands off after the server drains: exit for the supervisor to relaunch, or spawn the replacement directly. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ngzj2Rx9UGC35uLHAfErMp * fix(server): make the self-restart handoff drain fully before starting the successor The update/restore/wizard restart previously spawned the replacement while the old server was still serving, then SIGTERMed itself and hard-exited after 10s. That design failed in every documented deployment mode: under the shipped systemd unit the spawned child (same cgroup) was killed when the old main process exited and Restart=on-failure never relaunched a clean exit; on Windows the self-SIGTERM is unsupported and silently dropped, so graceful shutdown never ran — hub.GracefulStop (the only caller of LiveKitProcess.Stop) was skipped, orphaning livekit-server on TCP 7880/UDP 50000-60000 and dropping queued event/audit batches; and NSSM's relaunch raced the self-spawned replacement for the database lock. Admin handlers now perform only the on-disk swap and request a restart through an injected hook (admin.SetRestartHandoff). The main package's restart coordinator cancels the parent of run()'s signal.NotifyContext — the exact drain a SIGTERM triggers, on every platform — and after run() has fully torn down (listeners closed, hub and LiveKit stopped, queues flushed, DB closed and its lock released) main() performs the handoff: spawn the replacement in spawn mode, or exit 0 for the supervisor in supervised mode. A 90s backstop force-exits a wedged teardown; the DB-lock and bind retries demote to safety nets. A three-state guard (idle/busy/restart-pending) serializes update apply, backup restore, and setup-wizard restarts against each other: concurrent applies no longer race the same staged .new file or broadcast a spurious update_aborted, and conflicting requests get 409 UPDATE_IN_PROGRESS / RESTART_PENDING. The swap being free of process side effects also makes the apply success path unit-testable for the first time. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ngzj2Rx9UGC35uLHAfErMp * fix(server): errno-based bind-conflict detection, ACME bind retry, LiveKit Pdeathsig isAddrInUse now unwraps to the platform errno (EADDRINUSE; WSAEADDRINUSE 10048 on Windows) with the English strings kept only as fallback — the string-only match never fired on localized Windows, silently disabling the bind retry. The retry loop is extracted into serveWithBindRetry and now also covers the ACME :80 challenge server, which previously gave up on first conflict and stayed dead (breaking HTTP-01 renewals) until the next restart. The .old-binary boot cleanup retries briefly for the window where a spawn-mode predecessor has not fully exited. The companion livekit-server gets Pdeathsig SIGKILL on Linux so a parent killed without teardown (kill -9, OOM, backstop exit) cannot orphan it with the voice ports held. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ngzj2Rx9UGC35uLHAfErMp * docs(deploy): Restart=always unit and per-supervisor restart-mode guidance Restart=always is what lets the deliberate clean exit after a self-update/restore relaunch under systemd (systemctl stop is never auto-restarted; failure exits behave as before). Deployment docs gain the required NSSM AppEnvironmentExtra line, the Task Scheduler and Docker restart-policy notes, and the new drain-then-handoff update flow. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ngzj2Rx9UGC35uLHAfErMp --------- Co-authored-by: Claude <noreply@anthropic.com>
550 lines
16 KiB
Go
550 lines
16 KiB
Go
package config_test
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
|
|
"github.com/owncord/server/config"
|
|
)
|
|
|
|
func TestLoadDefaults(t *testing.T) {
|
|
// When no config file exists, Load should return defaults.
|
|
tmpDir := t.TempDir()
|
|
cfgPath := filepath.Join(tmpDir, "config.yaml")
|
|
|
|
cfg, err := config.Load(cfgPath)
|
|
if err != nil {
|
|
t.Fatalf("Load() with missing file returned error: %v", err)
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
got any
|
|
want any
|
|
}{
|
|
{"Server.Port", cfg.Server.Port, 8443},
|
|
{"Server.Name", cfg.Server.Name, "OwnCord Server"},
|
|
{"Server.DataDir", cfg.Server.DataDir, "data"},
|
|
{"Database.Path", cfg.Database.Path, "data/chatserver.db"},
|
|
{"TLS.Mode", cfg.TLS.Mode, "self_signed"},
|
|
{"Upload.MaxSizeMB", cfg.Upload.MaxSizeMB, 100},
|
|
{"Upload.StorageDir", cfg.Upload.StorageDir, "data/uploads"},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
if tc.got != tc.want {
|
|
t.Errorf("got %v, want %v", tc.got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestLoadGeneratesDefaultFile(t *testing.T) {
|
|
// When no config file exists, Load should write a default config.yaml.
|
|
tmpDir := t.TempDir()
|
|
cfgPath := filepath.Join(tmpDir, "config.yaml")
|
|
|
|
_, err := config.Load(cfgPath)
|
|
if err != nil {
|
|
t.Fatalf("Load() returned error: %v", err)
|
|
}
|
|
|
|
if _, statErr := os.Stat(cfgPath); os.IsNotExist(statErr) {
|
|
t.Error("Load() did not generate default config.yaml")
|
|
}
|
|
}
|
|
|
|
func TestLoadMergesYAML(t *testing.T) {
|
|
// When a YAML file exists with overrides, they should be merged with defaults.
|
|
tmpDir := t.TempDir()
|
|
cfgPath := filepath.Join(tmpDir, "config.yaml")
|
|
|
|
yaml := `
|
|
server:
|
|
port: 9000
|
|
name: "My Custom Server"
|
|
database:
|
|
path: "custom/path.db"
|
|
`
|
|
if err := os.WriteFile(cfgPath, []byte(yaml), 0o644); err != nil {
|
|
t.Fatalf("failed to write yaml: %v", err)
|
|
}
|
|
|
|
cfg, err := config.Load(cfgPath)
|
|
if err != nil {
|
|
t.Fatalf("Load() returned error: %v", err)
|
|
}
|
|
|
|
if cfg.Server.Port != 9000 {
|
|
t.Errorf("Server.Port = %d, want 9000", cfg.Server.Port)
|
|
}
|
|
if cfg.Server.Name != "My Custom Server" {
|
|
t.Errorf("Server.Name = %q, want 'My Custom Server'", cfg.Server.Name)
|
|
}
|
|
if cfg.Database.Path != "custom/path.db" {
|
|
t.Errorf("Database.Path = %q, want 'custom/path.db'", cfg.Database.Path)
|
|
}
|
|
// Non-overridden defaults should still be present.
|
|
if cfg.Server.DataDir != "data" {
|
|
t.Errorf("Server.DataDir = %q, want 'data'", cfg.Server.DataDir)
|
|
}
|
|
if cfg.Upload.MaxSizeMB != 100 {
|
|
t.Errorf("Upload.MaxSizeMB = %d, want 100", cfg.Upload.MaxSizeMB)
|
|
}
|
|
}
|
|
|
|
func TestLoadEnvironmentVariableOverrides(t *testing.T) {
|
|
// Environment variables with OWNCORD_ prefix should override config values.
|
|
tmpDir := t.TempDir()
|
|
cfgPath := filepath.Join(tmpDir, "config.yaml")
|
|
|
|
t.Setenv("OWNCORD_SERVER_PORT", "7777")
|
|
t.Setenv("OWNCORD_SERVER_NAME", "Env Server")
|
|
t.Setenv("OWNCORD_DATABASE_PATH", "env/path.db")
|
|
t.Setenv("OWNCORD_TLS_MODE", "manual")
|
|
|
|
cfg, err := config.Load(cfgPath)
|
|
if err != nil {
|
|
t.Fatalf("Load() returned error: %v", err)
|
|
}
|
|
|
|
if cfg.Server.Port != 7777 {
|
|
t.Errorf("Server.Port = %d, want 7777", cfg.Server.Port)
|
|
}
|
|
if cfg.Server.Name != "Env Server" {
|
|
t.Errorf("Server.Name = %q, want 'Env Server'", cfg.Server.Name)
|
|
}
|
|
if cfg.Database.Path != "env/path.db" {
|
|
t.Errorf("Database.Path = %q, want 'env/path.db'", cfg.Database.Path)
|
|
}
|
|
if cfg.TLS.Mode != "manual" {
|
|
t.Errorf("TLS.Mode = %q, want 'manual'", cfg.TLS.Mode)
|
|
}
|
|
}
|
|
|
|
func TestLoadInvalidYAML(t *testing.T) {
|
|
// Malformed YAML (bad indentation/tab mix) should return an error.
|
|
tmpDir := t.TempDir()
|
|
cfgPath := filepath.Join(tmpDir, "config.yaml")
|
|
|
|
// Tabs in YAML indentation are illegal per the YAML spec.
|
|
invalidYAML := "server:\n\tport: 9000\n"
|
|
if err := os.WriteFile(cfgPath, []byte(invalidYAML), 0o644); err != nil {
|
|
t.Fatalf("failed to write yaml: %v", err)
|
|
}
|
|
|
|
_, err := config.Load(cfgPath)
|
|
if err == nil {
|
|
t.Error("Load() with invalid YAML should return error, got nil")
|
|
}
|
|
}
|
|
|
|
func TestLoadTLSModeValues(t *testing.T) {
|
|
// Test that all valid TLS modes are accepted.
|
|
validModes := []string{"self_signed", "acme", "manual", "off"}
|
|
|
|
for _, mode := range validModes {
|
|
t.Run(mode, func(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
cfgPath := filepath.Join(tmpDir, "config.yaml")
|
|
|
|
yaml := "tls:\n mode: " + mode + "\n"
|
|
if err := os.WriteFile(cfgPath, []byte(yaml), 0o644); err != nil {
|
|
t.Fatalf("failed to write yaml: %v", err)
|
|
}
|
|
|
|
cfg, err := config.Load(cfgPath)
|
|
if err != nil {
|
|
t.Fatalf("Load() returned error: %v", err)
|
|
}
|
|
if cfg.TLS.Mode != mode {
|
|
t.Errorf("TLS.Mode = %q, want %q", cfg.TLS.Mode, mode)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestLoadEnvVarNoUnderscore(t *testing.T) {
|
|
// Test an env var that maps to a top-level key (no section separator).
|
|
// OWNCORD_PORT (no second underscore) — should not crash, just map to "port".
|
|
tmpDir := t.TempDir()
|
|
cfgPath := filepath.Join(tmpDir, "config.yaml")
|
|
|
|
t.Setenv("OWNCORD_PORT", "1234")
|
|
|
|
// Load should succeed without panicking.
|
|
_, err := config.Load(cfgPath)
|
|
if err != nil {
|
|
t.Fatalf("Load() returned error: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestLoadEnvVarStorageDir(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
cfgPath := filepath.Join(tmpDir, "config.yaml")
|
|
|
|
t.Setenv("OWNCORD_UPLOAD_STORAGE_DIR", "/mnt/data/uploads")
|
|
|
|
cfg, err := config.Load(cfgPath)
|
|
if err != nil {
|
|
t.Fatalf("Load() returned error: %v", err)
|
|
}
|
|
if cfg.Upload.StorageDir != "/mnt/data/uploads" {
|
|
t.Errorf("Upload.StorageDir = %q, want '/mnt/data/uploads'", cfg.Upload.StorageDir)
|
|
}
|
|
}
|
|
|
|
func TestLoadTLSCertAndKeyFields(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
cfgPath := filepath.Join(tmpDir, "config.yaml")
|
|
|
|
yaml := `
|
|
tls:
|
|
mode: "manual"
|
|
cert_file: "/etc/ssl/cert.pem"
|
|
key_file: "/etc/ssl/key.pem"
|
|
domain: "example.com"
|
|
`
|
|
if err := os.WriteFile(cfgPath, []byte(yaml), 0o644); err != nil {
|
|
t.Fatalf("failed to write yaml: %v", err)
|
|
}
|
|
|
|
cfg, err := config.Load(cfgPath)
|
|
if err != nil {
|
|
t.Fatalf("Load() returned error: %v", err)
|
|
}
|
|
if cfg.TLS.CertFile != "/etc/ssl/cert.pem" {
|
|
t.Errorf("TLS.CertFile = %q, want '/etc/ssl/cert.pem'", cfg.TLS.CertFile)
|
|
}
|
|
if cfg.TLS.KeyFile != "/etc/ssl/key.pem" {
|
|
t.Errorf("TLS.KeyFile = %q, want '/etc/ssl/key.pem'", cfg.TLS.KeyFile)
|
|
}
|
|
if cfg.TLS.Domain != "example.com" {
|
|
t.Errorf("TLS.Domain = %q, want 'example.com'", cfg.TLS.Domain)
|
|
}
|
|
}
|
|
|
|
func TestLoadVoiceConfigDefaults(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
cfgPath := filepath.Join(tmpDir, "config.yaml")
|
|
|
|
cfg, err := config.Load(cfgPath)
|
|
if err != nil {
|
|
t.Fatalf("Load() returned error: %v", err)
|
|
}
|
|
|
|
if cfg.Voice.Quality != "medium" {
|
|
t.Errorf("Voice.Quality = %q, want 'medium'", cfg.Voice.Quality)
|
|
}
|
|
if cfg.Voice.LiveKitURL != "ws://localhost:7880" {
|
|
t.Errorf("Voice.LiveKitURL = %q, want 'ws://localhost:7880'", cfg.Voice.LiveKitURL)
|
|
}
|
|
// Key and secret should be auto-generated (non-empty, not the old defaults).
|
|
if cfg.Voice.LiveKitAPIKey == "" {
|
|
t.Error("Voice.LiveKitAPIKey should be auto-generated, got empty")
|
|
}
|
|
if cfg.Voice.LiveKitAPIKey == config.DefaultLiveKitAPIKey {
|
|
t.Error("Voice.LiveKitAPIKey should not be the well-known default")
|
|
}
|
|
if cfg.Voice.LiveKitAPISecret == "" {
|
|
t.Error("Voice.LiveKitAPISecret should be auto-generated, got empty")
|
|
}
|
|
if cfg.Voice.LiveKitAPISecret == config.DefaultLiveKitAPISecret {
|
|
t.Error("Voice.LiveKitAPISecret should not be the well-known default")
|
|
}
|
|
}
|
|
|
|
func TestLoadVoiceConfigFromYAML(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
cfgPath := filepath.Join(tmpDir, "config.yaml")
|
|
|
|
yaml := `
|
|
voice:
|
|
quality: high
|
|
livekit_api_key: "mykey"
|
|
livekit_api_secret: "mysecret"
|
|
livekit_url: "ws://lk.example.com:7880"
|
|
advertise_internal_ip: true
|
|
`
|
|
if err := os.WriteFile(cfgPath, []byte(yaml), 0o644); err != nil {
|
|
t.Fatalf("failed to write yaml: %v", err)
|
|
}
|
|
|
|
cfg, err := config.Load(cfgPath)
|
|
if err != nil {
|
|
t.Fatalf("Load() returned error: %v", err)
|
|
}
|
|
|
|
if cfg.Voice.Quality != "high" {
|
|
t.Errorf("Voice.Quality = %q, want 'high'", cfg.Voice.Quality)
|
|
}
|
|
if cfg.Voice.LiveKitAPIKey != "mykey" {
|
|
t.Errorf("Voice.LiveKitAPIKey = %q, want 'mykey'", cfg.Voice.LiveKitAPIKey)
|
|
}
|
|
if cfg.Voice.LiveKitAPISecret != "mysecret" {
|
|
t.Errorf("Voice.LiveKitAPISecret = %q, want 'mysecret'", cfg.Voice.LiveKitAPISecret)
|
|
}
|
|
if cfg.Voice.LiveKitURL != "ws://lk.example.com:7880" {
|
|
t.Errorf("Voice.LiveKitURL = %q, want 'ws://lk.example.com:7880'", cfg.Voice.LiveKitURL)
|
|
}
|
|
if !cfg.Voice.AdvertiseInternalIP {
|
|
t.Error("Voice.AdvertiseInternalIP = false, want true")
|
|
}
|
|
}
|
|
|
|
func TestLoadVoiceAdvertiseInternalIPFromEnv(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
cfgPath := filepath.Join(tmpDir, "config.yaml")
|
|
if err := os.WriteFile(cfgPath, []byte("voice:\n quality: high\n"), 0o644); err != nil {
|
|
t.Fatalf("failed to write yaml: %v", err)
|
|
}
|
|
|
|
t.Setenv("OWNCORD_VOICE_ADVERTISE_INTERNAL_IP", "true")
|
|
|
|
cfg, err := config.Load(cfgPath)
|
|
if err != nil {
|
|
t.Fatalf("Load() returned error: %v", err)
|
|
}
|
|
if !cfg.Voice.AdvertiseInternalIP {
|
|
t.Error("Voice.AdvertiseInternalIP = false, want true from env override")
|
|
}
|
|
}
|
|
|
|
func TestLoadEnvOverridesPrecedenceOverYAML(t *testing.T) {
|
|
// Env vars should override values set in the YAML file.
|
|
tmpDir := t.TempDir()
|
|
cfgPath := filepath.Join(tmpDir, "config.yaml")
|
|
|
|
yaml := `
|
|
server:
|
|
port: 9000
|
|
name: "YAML Server"
|
|
`
|
|
if err := os.WriteFile(cfgPath, []byte(yaml), 0o644); err != nil {
|
|
t.Fatalf("failed to write yaml: %v", err)
|
|
}
|
|
|
|
t.Setenv("OWNCORD_SERVER_PORT", "5555")
|
|
t.Setenv("OWNCORD_SERVER_NAME", "Env Wins")
|
|
|
|
cfg, err := config.Load(cfgPath)
|
|
if err != nil {
|
|
t.Fatalf("Load() returned error: %v", err)
|
|
}
|
|
|
|
if cfg.Server.Port != 5555 {
|
|
t.Errorf("Server.Port = %d, want 5555 (env should override YAML)", cfg.Server.Port)
|
|
}
|
|
if cfg.Server.Name != "Env Wins" {
|
|
t.Errorf("Server.Name = %q, want 'Env Wins' (env should override YAML)", cfg.Server.Name)
|
|
}
|
|
}
|
|
|
|
func TestLoadUnreadableConfigFile(t *testing.T) {
|
|
// A config file that exists but can't be read should return an error.
|
|
tmpDir := t.TempDir()
|
|
cfgPath := filepath.Join(tmpDir, "config.yaml")
|
|
|
|
// Create a directory where a file is expected — os.ReadFile will fail.
|
|
if err := os.Mkdir(cfgPath, 0o755); err != nil {
|
|
t.Fatalf("failed to create directory: %v", err)
|
|
}
|
|
|
|
_, err := config.Load(cfgPath)
|
|
if err == nil {
|
|
t.Error("Load() should error when config path is a directory")
|
|
}
|
|
}
|
|
|
|
func TestLoadVoiceDefaultCredentialsCleared(t *testing.T) {
|
|
// When YAML sets the well-known default dev credentials, Load should clear them.
|
|
tmpDir := t.TempDir()
|
|
cfgPath := filepath.Join(tmpDir, "config.yaml")
|
|
|
|
yaml := `
|
|
voice:
|
|
livekit_api_key: "devkey"
|
|
livekit_api_secret: "owncord-dev-secret-key-min-32chars"
|
|
`
|
|
if err := os.WriteFile(cfgPath, []byte(yaml), 0o644); err != nil {
|
|
t.Fatalf("failed to write yaml: %v", err)
|
|
}
|
|
|
|
cfg, err := config.Load(cfgPath)
|
|
if err != nil {
|
|
t.Fatalf("Load() returned error: %v", err)
|
|
}
|
|
|
|
if cfg.Voice.LiveKitAPIKey != "" {
|
|
t.Errorf("Voice.LiveKitAPIKey = %q, want empty (dev creds should be cleared)", cfg.Voice.LiveKitAPIKey)
|
|
}
|
|
if cfg.Voice.LiveKitAPISecret != "" {
|
|
t.Errorf("Voice.LiveKitAPISecret = %q, want empty (dev creds should be cleared)", cfg.Voice.LiveKitAPISecret)
|
|
}
|
|
}
|
|
|
|
func TestLoadVoiceEmptySectionGetsDefaults(t *testing.T) {
|
|
// An empty voice section in YAML should still get defaults applied.
|
|
tmpDir := t.TempDir()
|
|
cfgPath := filepath.Join(tmpDir, "config.yaml")
|
|
|
|
yaml := "voice:\n"
|
|
if err := os.WriteFile(cfgPath, []byte(yaml), 0o644); err != nil {
|
|
t.Fatalf("failed to write yaml: %v", err)
|
|
}
|
|
|
|
cfg, err := config.Load(cfgPath)
|
|
if err != nil {
|
|
t.Fatalf("Load() returned error: %v", err)
|
|
}
|
|
|
|
if cfg.Voice.LiveKitURL != "ws://localhost:7880" {
|
|
t.Errorf("Voice.LiveKitURL = %q, want default 'ws://localhost:7880'", cfg.Voice.LiveKitURL)
|
|
}
|
|
if cfg.Voice.Quality != "medium" {
|
|
t.Errorf("Voice.Quality = %q, want default 'medium'", cfg.Voice.Quality)
|
|
}
|
|
// Key and secret should be auto-generated (non-empty).
|
|
if cfg.Voice.LiveKitAPIKey == "" {
|
|
t.Error("Voice.LiveKitAPIKey should be auto-generated, got empty")
|
|
}
|
|
if cfg.Voice.LiveKitAPISecret == "" {
|
|
t.Error("Voice.LiveKitAPISecret should be auto-generated, got empty")
|
|
}
|
|
}
|
|
|
|
func TestIsDefaultVoiceCredentials(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
key string
|
|
secret string
|
|
want bool
|
|
}{
|
|
{"both default", config.DefaultLiveKitAPIKey, config.DefaultLiveKitAPISecret, true},
|
|
{"only key default", config.DefaultLiveKitAPIKey, "custom-secret-long-enough-32chars", true},
|
|
{"only secret default", "custom-key", config.DefaultLiveKitAPISecret, true},
|
|
{"neither default", "custom-key", "custom-secret-long-enough-32chars", false},
|
|
{"both empty", "", "", false},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
v := &config.VoiceConfig{
|
|
LiveKitAPIKey: tc.key,
|
|
LiveKitAPISecret: tc.secret,
|
|
}
|
|
got := config.IsDefaultVoiceCredentials(v)
|
|
if got != tc.want {
|
|
t.Errorf("IsDefaultVoiceCredentials() = %v, want %v", got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestLoadGitHubToken(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
cfgPath := filepath.Join(tmpDir, "config.yaml")
|
|
|
|
yaml := `
|
|
github:
|
|
token: "ghp_test123"
|
|
`
|
|
if err := os.WriteFile(cfgPath, []byte(yaml), 0o644); err != nil {
|
|
t.Fatalf("failed to write yaml: %v", err)
|
|
}
|
|
|
|
cfg, err := config.Load(cfgPath)
|
|
if err != nil {
|
|
t.Fatalf("Load() returned error: %v", err)
|
|
}
|
|
if cfg.GitHub.Token != "ghp_test123" {
|
|
t.Errorf("GitHub.Token = %q, want 'ghp_test123'", cfg.GitHub.Token)
|
|
}
|
|
}
|
|
|
|
func TestLoadUploadBoundaryValues(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
cfgPath := filepath.Join(tmpDir, "config.yaml")
|
|
|
|
yaml := "upload:\n max_size_mb: 0\n"
|
|
if err := os.WriteFile(cfgPath, []byte(yaml), 0o644); err != nil {
|
|
t.Fatalf("failed to write yaml: %v", err)
|
|
}
|
|
|
|
cfg, err := config.Load(cfgPath)
|
|
if err != nil {
|
|
t.Fatalf("Load() returned error: %v", err)
|
|
}
|
|
if cfg.Upload.MaxSizeMB != 0 {
|
|
t.Errorf("Upload.MaxSizeMB = %d, want 0", cfg.Upload.MaxSizeMB)
|
|
}
|
|
}
|
|
|
|
func TestLoadEnvOverride_EventPersistence(t *testing.T) {
|
|
// event_persistence is the only multi-word config section; cutting the
|
|
// env key at the first underscore produces the dead path
|
|
// event.persistence_enabled and the documented override is silently
|
|
// dropped (docs/server-configuration.md).
|
|
tmpDir := t.TempDir()
|
|
cfgPath := filepath.Join(tmpDir, "config.yaml")
|
|
|
|
// Enabled defaults to true, so override it to false — the meaningful
|
|
// direction for proving the env path is alive.
|
|
t.Setenv("OWNCORD_EVENT_PERSISTENCE_ENABLED", "false")
|
|
t.Setenv("OWNCORD_EVENT_PERSISTENCE_RETENTION_HOURS", "48")
|
|
|
|
cfg, err := config.Load(cfgPath)
|
|
if err != nil {
|
|
t.Fatalf("Load() returned error: %v", err)
|
|
}
|
|
if cfg.EventPersistence.Enabled {
|
|
t.Error("EventPersistence.Enabled = true, want env override false")
|
|
}
|
|
if cfg.EventPersistence.RetentionHours != 48 {
|
|
t.Errorf("EventPersistence.RetentionHours = %d, want 48", cfg.EventPersistence.RetentionHours)
|
|
}
|
|
}
|
|
|
|
func TestLoadRestartMode(t *testing.T) {
|
|
// server.restart_mode drives the self-restart handoff (see main.go's
|
|
// resolveRestartMode): default "auto", overridable via YAML and via
|
|
// OWNCORD_SERVER_RESTART_MODE — the env case pins envKeyToKoanf's
|
|
// server_restart_mode -> server.restart_mode mapping.
|
|
t.Run("default", func(t *testing.T) {
|
|
cfg, err := config.Load(filepath.Join(t.TempDir(), "config.yaml"))
|
|
if err != nil {
|
|
t.Fatalf("Load() returned error: %v", err)
|
|
}
|
|
if cfg.Server.RestartMode != "auto" {
|
|
t.Errorf("Server.RestartMode = %q, want %q", cfg.Server.RestartMode, "auto")
|
|
}
|
|
})
|
|
|
|
t.Run("yaml override", func(t *testing.T) {
|
|
cfgPath := filepath.Join(t.TempDir(), "config.yaml")
|
|
yaml := "server:\n restart_mode: \"supervised\"\n"
|
|
if err := os.WriteFile(cfgPath, []byte(yaml), 0o644); err != nil {
|
|
t.Fatalf("failed to write yaml: %v", err)
|
|
}
|
|
cfg, err := config.Load(cfgPath)
|
|
if err != nil {
|
|
t.Fatalf("Load() returned error: %v", err)
|
|
}
|
|
if cfg.Server.RestartMode != "supervised" {
|
|
t.Errorf("Server.RestartMode = %q, want %q", cfg.Server.RestartMode, "supervised")
|
|
}
|
|
})
|
|
|
|
t.Run("env override", func(t *testing.T) {
|
|
t.Setenv("OWNCORD_SERVER_RESTART_MODE", "spawn")
|
|
cfg, err := config.Load(filepath.Join(t.TempDir(), "config.yaml"))
|
|
if err != nil {
|
|
t.Fatalf("Load() returned error: %v", err)
|
|
}
|
|
if cfg.Server.RestartMode != "spawn" {
|
|
t.Errorf("Server.RestartMode = %q, want %q", cfg.Server.RestartMode, "spawn")
|
|
}
|
|
})
|
|
}
|