package config import ( "os" "strings" "testing" "time" ) // unset removes an env key for the duration of the test, restoring it after. // Needed to observe a default for a key read with LookupEnv (where present-empty // means "explicitly disabled", not "use default"). func unset(t *testing.T, key string) { t.Helper() if old, ok := os.LookupEnv(key); ok { t.Cleanup(func() { if err := os.Setenv(key, old); err != nil { t.Fatalf("restore %s: %v", key, err) } }) } if err := os.Unsetenv(key); err != nil { t.Fatalf("unset %s: %v", key, err) } } // setEnv sets env vars for the test and clears them afterward, so cases don't // leak into one another. t.Setenv handles restoration. func setEnv(t *testing.T, kv map[string]string) { t.Helper() for k, v := range kv { t.Setenv(k, v) } } func TestLoad_AppliesDefaults(t *testing.T) { // Clear the ones with defaults so we observe the fallback, not the host env. setEnv(t, map[string]string{ "TAPIR_GATEWAY_URL": "", "TAPIR_SUMMARIZER_MODEL": "", "TAPIR_YT_TOKEN_REF": "", "TAPIR_OAUTH_REDIRECT_ADDR": "", "TAPIR_SUMMARIZER_TIMEOUT": "", "TAPIR_POLL_INTERVAL": "", }) c, err := Load() if err != nil { t.Fatalf("Load: %v", err) } if c.GatewayURL != defaultGatewayURL { t.Errorf("GatewayURL = %q, want default %q", c.GatewayURL, defaultGatewayURL) } if c.SummarizerModel != defaultSummarizerModel { t.Errorf("SummarizerModel = %q, want default %q", c.SummarizerModel, defaultSummarizerModel) } if c.YTTokenRef != defaultYTTokenRef { t.Errorf("YTTokenRef = %q, want default %q", c.YTTokenRef, defaultYTTokenRef) } if c.SummarizerTimeout != defaultSummarizerTimeout { t.Errorf("SummarizerTimeout = %v, want default %v", c.SummarizerTimeout, defaultSummarizerTimeout) } if c.PollInterval != 0 { t.Errorf("PollInterval = %v, want 0 (run once)", c.PollInterval) } if c.FetchBackoff != defaultFetchBackoff { t.Errorf("FetchBackoff = %v, want default %v", c.FetchBackoff, defaultFetchBackoff) } if c.AutoSummarizeWindow != defaultAutoSummarizeWindow { t.Errorf("AutoSummarizeWindow = %v, want default %v", c.AutoSummarizeWindow, defaultAutoSummarizeWindow) } } func TestLoad_SummarizerChainDefaults(t *testing.T) { setEnv(t, map[string]string{ "TAPIR_SUMMARIZER_MODEL": "", "TAPIR_SUMMARY_MAX_TOKENS": "", "TAPIR_MAX_TRANSCRIPT_CHARS": "", }) unset(t, "TAPIR_FALLBACK_MODEL") unset(t, "TAPIR_CLOUD_FALLBACK_MODEL") c, err := Load() if err != nil { t.Fatalf("Load: %v", err) } if c.FallbackModel != defaultFallbackModel { t.Errorf("FallbackModel = %q, want %q", c.FallbackModel, defaultFallbackModel) } if c.CloudFallbackModel != defaultCloudFallbackModel { t.Errorf("CloudFallbackModel = %q, want %q", c.CloudFallbackModel, defaultCloudFallbackModel) } if c.SummaryMaxTokens != defaultSummaryMaxTokens { t.Errorf("SummaryMaxTokens = %d, want %d", c.SummaryMaxTokens, defaultSummaryMaxTokens) } if c.MaxTranscriptChars != defaultMaxTranscriptChars { t.Errorf("MaxTranscriptChars = %d, want %d", c.MaxTranscriptChars, defaultMaxTranscriptChars) } } // An explicitly empty cloud-fallback env disables external routing — the lever a // client deployment pulls so content never leaves the local stack. func TestLoad_EmptyCloudFallbackDisables(t *testing.T) { t.Setenv("TAPIR_CLOUD_FALLBACK_MODEL", "") c, err := Load() if err != nil { t.Fatalf("Load: %v", err) } if c.CloudFallbackModel != "" { t.Errorf("CloudFallbackModel = %q, want empty (disabled)", c.CloudFallbackModel) } } func TestLoad_OnboardSummarizeCount(t *testing.T) { cases := []struct { name, env string want int }{ {"default", "", defaultOnboardSummarizeCount}, {"explicit", "4", 4}, {"zero disables", "0", 0}, {"clamped to hard cap", "50", maxOnboardSummarizeCount}, {"negative clamps to zero", "-3", 0}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { setEnv(t, map[string]string{"TAPIR_ONBOARD_SUMMARIZE_COUNT": c.env}) cfg, err := Load() if err != nil { t.Fatalf("Load: %v", err) } if cfg.OnboardSummarizeCount != c.want { t.Fatalf("OnboardSummarizeCount = %d, want %d", cfg.OnboardSummarizeCount, c.want) } }) } } func TestLoad_OnboardSummarizeCountInvalid(t *testing.T) { setEnv(t, map[string]string{"TAPIR_ONBOARD_SUMMARIZE_COUNT": "three"}) if _, err := Load(); err == nil { t.Fatal("Load: want error for non-numeric TAPIR_ONBOARD_SUMMARIZE_COUNT") } } func TestLoad_ParsesValues(t *testing.T) { setEnv(t, map[string]string{ "TAPIR_USER_ID": "11111111-1111-1111-1111-111111111111", "TAPIR_GATEWAY_URL": "http://example/v1", "TAPIR_GATEWAY_KEY": "sk-test", "TAPIR_SUMMARIZER_MODEL": "iguana/deepseek-r1-14b", "TAPIR_SUMMARIZER_TIMEOUT": "90s", "TAPIR_DB_DSN": "postgres://x", "TAPIR_POLL_INTERVAL": "10m", "TAPIR_FETCH_BACKOFF": "30m", "TAPIR_AUTO_SUMMARIZE_WINDOW": "48h", }) c, err := Load() if err != nil { t.Fatalf("Load: %v", err) } if c.UserID != "11111111-1111-1111-1111-111111111111" { t.Errorf("UserID = %q", c.UserID) } if c.GatewayKey != "sk-test" { t.Errorf("GatewayKey = %q", c.GatewayKey) } if c.SummarizerModel != "iguana/deepseek-r1-14b" { t.Errorf("SummarizerModel = %q", c.SummarizerModel) } if c.SummarizerTimeout != 90*time.Second { t.Errorf("SummarizerTimeout = %v, want 90s", c.SummarizerTimeout) } if c.PollInterval != 10*time.Minute { t.Errorf("PollInterval = %v, want 10m", c.PollInterval) } if c.FetchBackoff != 30*time.Minute { t.Errorf("FetchBackoff = %v, want 30m", c.FetchBackoff) } if c.AutoSummarizeWindow != 48*time.Hour { t.Errorf("AutoSummarizeWindow = %v, want 48h", c.AutoSummarizeWindow) } } func TestLoad_RejectsBadDuration(t *testing.T) { setEnv(t, map[string]string{"TAPIR_SUMMARIZER_TIMEOUT": "not-a-duration"}) if _, err := Load(); err == nil { t.Fatal("want error on unparsable duration, got nil") } } func TestValidateForRun_ReportsMissing(t *testing.T) { c := Config{} // nothing set err := c.ValidateForRun() if err == nil { t.Fatal("want error when required run fields are missing") } for _, want := range []string{"TAPIR_USER_ID", "TAPIR_DB_DSN", "TAPIR_YT_CLIENT_ID"} { if !strings.Contains(err.Error(), want) { t.Errorf("error %q must name missing field %q", err, want) } } } func TestValidateForAuth_PassesWhenComplete(t *testing.T) { c := Config{ YTClientID: "id", YTClientSecret: "secret", YTTokenRef: "youtube/refresh_token", SecretsFile: "/tmp/secrets.json", } if err := c.ValidateForAuth(); err != nil { t.Errorf("ValidateForAuth: unexpected error %v", err) } }