feat(config): typed env-driven configuration
Parse TAPIR_* env into a typed Config with homelab defaults (gateway URL, summarizer model, token ref, redirect addr). Secrets (gateway key, OAuth client secret) come from env only; the refresh token never lives here — it is addressed by an opaque ref behind the SecretStore port. Per-command validation (ValidateForAuth/ValidateForRun) so each command demands only what it needs. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,112 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
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",
|
||||
})
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user