Files
root 9d6181260a
Desktop Client Build / Resolve Build Metadata (push) Successful in 43s
Frontend CI / Frontend (push) Successful in 3m49s
Backend CI / Backend (push) Failing after 7m10s
Desktop Client Build / Build Desktop Client (push) Successful in 23m4s
Desktop Client Build / Publish Client Artifacts to NAS (push) Successful in 28s
feat(media-supply): add media resource supply marketplace
Introduce an end-to-end media-supply feature: tenant-side resource sync
service/worker backed by a Meijiequan supplier client, ops-side management
APIs, and admin/ops web views for resources, orders, favorites and
submission. Adds a shared digitocr helper, MediaSupply config blocks for
tenant and ops, shared types, and migrations for supplier media resources,
price overrides, customer visibility and order refunds.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 23:17:01 +08:00

903 lines
27 KiB
Go

package config
import (
"context"
"os"
"path/filepath"
"testing"
"time"
)
func TestLoadPrefersEnvLLMAPIKey(t *testing.T) {
t.Setenv("LLM_API_KEY", "env-priority-key")
configPath := writeTestConfig(t, `
llm:
provider: ark
api_key: "config-file-key"
`)
cfg, err := Load(configPath)
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if cfg.LLM.APIKey != "env-priority-key" {
t.Fatalf("expected env api key, got %q", cfg.LLM.APIKey)
}
}
func TestDatabaseConfigDSNEscapesCredentials(t *testing.T) {
cfg := DatabaseConfig{
Host: "db.internal",
Port: 5432,
User: "geo",
Password: "p@ss:word/with?symbols",
DBName: "geo",
SSLMode: "disable",
}
got := cfg.DSN()
want := "postgres://geo:p%40ss%3Aword%2Fwith%3Fsymbols@db.internal:5432/geo?sslmode=disable"
if got != want {
t.Fatalf("DSN() = %q, want %q", got, want)
}
}
func TestLoadFallsBackToConfigLLMAPIKey(t *testing.T) {
t.Setenv("LLM_API_KEY", "")
configPath := writeTestConfig(t, `
llm:
provider: ark
api_key: "config-file-key"
`)
cfg, err := Load(configPath)
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if cfg.LLM.APIKey != "config-file-key" {
t.Fatalf("expected config api key, got %q", cfg.LLM.APIKey)
}
}
func TestLoadPrefersEnvRetrievalAndQdrantSettings(t *testing.T) {
t.Setenv("SILICONFLOW_API_KEY", "siliconflow-env-key")
t.Setenv("SILICONFLOW_EMBEDDING_MODEL", "env-embedding")
t.Setenv("SILICONFLOW_RERANKER_MODEL", "env-reranker")
t.Setenv("QDRANT_API_KEY", "qdrant-env-key")
t.Setenv("QDRANT_URL", "qdrant-env:6334")
t.Setenv("QDRANT_COLLECTION", "env_collection")
configPath := writeTestConfig(t, `
qdrant:
url: qdrant-config:6334
api_key: "qdrant-config-key"
collection: "config_collection"
retrieval:
provider: siliconflow
api_key: "retrieval-config-key"
embedding_model: "config-embedding"
reranker_model: "config-reranker"
`)
cfg, err := Load(configPath)
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if cfg.Retrieval.APIKey != "siliconflow-env-key" {
t.Fatalf("expected env retrieval api key, got %q", cfg.Retrieval.APIKey)
}
if cfg.Retrieval.EmbeddingModel != "env-embedding" {
t.Fatalf("expected env embedding model, got %q", cfg.Retrieval.EmbeddingModel)
}
if cfg.Retrieval.RerankerModel != "env-reranker" {
t.Fatalf("expected env reranker model, got %q", cfg.Retrieval.RerankerModel)
}
if cfg.Qdrant.APIKey != "qdrant-env-key" {
t.Fatalf("expected env qdrant api key, got %q", cfg.Qdrant.APIKey)
}
if cfg.Qdrant.URL != "qdrant-env:6334" {
t.Fatalf("expected env qdrant url, got %q", cfg.Qdrant.URL)
}
if cfg.Qdrant.Collection != "env_collection" {
t.Fatalf("expected env qdrant collection, got %q", cfg.Qdrant.Collection)
}
}
func TestLoadAppliesObjectStorageEnvOverrides(t *testing.T) {
t.Setenv("OBJECT_STORAGE_BUCKET_ACL", "private")
t.Setenv("OBJECT_STORAGE_OBJECT_ACL", "public-read")
t.Setenv("OBJECT_STORAGE_SIGNED_URL_TTL", "45m")
t.Setenv("OBJECT_STORAGE_FORCE_PATH_STYLE", "true")
configPath := writeTestConfig(t, `
object_storage:
bucket_acl: public-read
object_acl: private
signed_url_ttl: 10m
force_path_style: false
`)
cfg, err := Load(configPath)
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if cfg.ObjectStorage.BucketACL != "private" {
t.Fatalf("expected env bucket acl, got %q", cfg.ObjectStorage.BucketACL)
}
if cfg.ObjectStorage.ObjectACL != "public-read" {
t.Fatalf("expected env object acl, got %q", cfg.ObjectStorage.ObjectACL)
}
if cfg.ObjectStorage.SignedURLTTL != 45*time.Minute {
t.Fatalf("expected env signed url ttl, got %s", cfg.ObjectStorage.SignedURLTTL)
}
if !cfg.ObjectStorage.ForcePathStyle {
t.Fatal("expected env force path style true")
}
}
func TestLoadAppliesBrandLibraryDefaults(t *testing.T) {
configPath := writeTestConfig(t, `
brand_library: {}
`)
cfg, err := Load(configPath)
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if cfg.BrandLibrary.FreeBrandLimit != 1 {
t.Fatalf("expected default free brand limit 1, got %d", cfg.BrandLibrary.FreeBrandLimit)
}
if cfg.BrandLibrary.PaidBrandLimit != 2 {
t.Fatalf("expected default paid brand limit 2, got %d", cfg.BrandLibrary.PaidBrandLimit)
}
if cfg.BrandLibrary.MaxKeywords != 5 {
t.Fatalf("expected default max keywords 5, got %d", cfg.BrandLibrary.MaxKeywords)
}
if cfg.BrandLibrary.MaxQuestionsPerKeyword != 5 {
t.Fatalf("expected default max questions per keyword 5, got %d", cfg.BrandLibrary.MaxQuestionsPerKeyword)
}
}
func TestLoadAppliesServerTrustedProxyDefaultsAndOverrides(t *testing.T) {
defaultConfigPath := writeTestConfig(t, `
server: {}
`)
cfg, err := Load(defaultConfigPath)
if err != nil {
t.Fatalf("Load() default error = %v", err)
}
if got := cfg.Server.TrustedProxies; len(got) != len(DefaultTrustedProxies()) {
t.Fatalf("expected default trusted proxies, got %#v", got)
}
overrideConfigPath := writeTestConfig(t, `
server:
trusted_proxies:
- " 10.42.0.0/16 "
- ""
- "192.168.1.10"
`)
cfg, err = Load(overrideConfigPath)
if err != nil {
t.Fatalf("Load() override error = %v", err)
}
want := []string{"10.42.0.0/16", "192.168.1.10"}
if len(cfg.Server.TrustedProxies) != len(want) {
t.Fatalf("trusted proxies = %#v, want %#v", cfg.Server.TrustedProxies, want)
}
for i := range want {
if cfg.Server.TrustedProxies[i] != want[i] {
t.Fatalf("trusted proxies = %#v, want %#v", cfg.Server.TrustedProxies, want)
}
}
}
func TestLoadAppliesServerSecurityDefaultsAndOriginOverrides(t *testing.T) {
t.Setenv("SERVER_ALLOWED_ORIGINS", "https://admin.example.com, https://ops.example.com")
configPath := writeTestConfig(t, `
server: {}
`)
cfg, err := Load(configPath)
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if !cfg.Server.SecurityHeaders.Enabled {
t.Fatalf("expected security headers enabled by default")
}
wantOrigins := []string{"https://admin.example.com", "https://ops.example.com"}
if len(cfg.Server.AllowedOrigins) != len(wantOrigins) {
t.Fatalf("allowed origins = %#v, want %#v", cfg.Server.AllowedOrigins, wantOrigins)
}
for i := range wantOrigins {
if cfg.Server.AllowedOrigins[i] != wantOrigins[i] {
t.Fatalf("allowed origins = %#v, want %#v", cfg.Server.AllowedOrigins, wantOrigins)
}
}
}
func TestLoadAppliesSchedulerHTTPDefaultsAndDisableSentinel(t *testing.T) {
defaultConfigPath := writeTestConfig(t, `
scheduler: {}
`)
cfg, err := Load(defaultConfigPath)
if err != nil {
t.Fatalf("Load() default error = %v", err)
}
if cfg.Scheduler.HTTPHost != "127.0.0.1" {
t.Fatalf("expected default scheduler http host 127.0.0.1, got %q", cfg.Scheduler.HTTPHost)
}
if cfg.Scheduler.HTTPPort != 8081 {
t.Fatalf("expected default scheduler http port 8081, got %d", cfg.Scheduler.HTTPPort)
}
disabledConfigPath := writeTestConfig(t, `
scheduler:
http_port: -1
`)
cfg, err = Load(disabledConfigPath)
if err != nil {
t.Fatalf("Load() disabled error = %v", err)
}
if cfg.Scheduler.HTTPPort != -1 {
t.Fatalf("expected scheduler http port -1 to disable server, got %d", cfg.Scheduler.HTTPPort)
}
}
func TestLoadAppliesRedisAndCacheDefaults(t *testing.T) {
configPath := writeTestConfig(t, `
redis: {}
cache: {}
`)
cfg, err := Load(configPath)
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if cfg.Redis.Addr != "localhost:6379" {
t.Fatalf("expected default redis addr, got %q", cfg.Redis.Addr)
}
if cfg.Redis.DialTimeout != 5*time.Second {
t.Fatalf("expected default redis dial timeout 5s, got %s", cfg.Redis.DialTimeout)
}
if cfg.Redis.ReadTimeout != 3*time.Second {
t.Fatalf("expected default redis read timeout 3s, got %s", cfg.Redis.ReadTimeout)
}
if cfg.Redis.WriteTimeout != 3*time.Second {
t.Fatalf("expected default redis write timeout 3s, got %s", cfg.Redis.WriteTimeout)
}
if cfg.Redis.PoolSize != 16 {
t.Fatalf("expected default redis pool size 16, got %d", cfg.Redis.PoolSize)
}
if cfg.Redis.PoolTimeout != 4*time.Second {
t.Fatalf("expected default redis pool timeout 4s, got %s", cfg.Redis.PoolTimeout)
}
if cfg.Cache.Driver != "redis" {
t.Fatalf("expected default cache driver redis, got %q", cfg.Cache.Driver)
}
if cfg.Cache.Namespace != "geo" {
t.Fatalf("expected default cache namespace geo, got %q", cfg.Cache.Namespace)
}
if cfg.Cache.JitterRatio != 0.1 {
t.Fatalf("expected default jitter ratio 0.1, got %f", cfg.Cache.JitterRatio)
}
if !cfg.Cache.MetricsEnabled {
t.Fatalf("expected cache metrics enabled by default")
}
if cfg.Cache.AsyncFillWorkers != 2 || cfg.Cache.AsyncFillBuffer != 1024 || cfg.Cache.AsyncFillTimeout != 2*time.Second {
t.Fatalf("unexpected async fill defaults: workers=%d buffer=%d timeout=%s", cfg.Cache.AsyncFillWorkers, cfg.Cache.AsyncFillBuffer, cfg.Cache.AsyncFillTimeout)
}
if cfg.Cache.L1TTL != 30*time.Second {
t.Fatalf("expected default l1 ttl 30s, got %s", cfg.Cache.L1TTL)
}
if cfg.Cache.DeleteScanCount != 500 {
t.Fatalf("expected default delete scan count 500, got %d", cfg.Cache.DeleteScanCount)
}
}
func TestLoadAppliesRedisAndCacheOverrides(t *testing.T) {
configPath := writeTestConfig(t, `
redis:
addr: redis.internal:6380
password: secret
db: 2
dial_timeout: 7s
read_timeout: 8s
write_timeout: 9s
pool_size: 32
min_idle_conns: 4
max_retries: 5
pool_timeout: 6s
tls_enabled: true
cache:
driver: memory
namespace: tenant-cache
jitter_ratio: 0.25
metrics_enabled: false
async_fill_enabled: true
async_fill_workers: 3
async_fill_buffer: 64
async_fill_timeout: 1500ms
l1_enabled: true
l1_ttl: 5s
delete_scan_count: 1200
`)
cfg, err := Load(configPath)
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if cfg.Redis.Addr != "redis.internal:6380" || cfg.Redis.Password != "secret" || cfg.Redis.DB != 2 {
t.Fatalf("unexpected redis endpoint config: %#v", cfg.Redis)
}
if cfg.Redis.DialTimeout != 7*time.Second || cfg.Redis.ReadTimeout != 8*time.Second || cfg.Redis.WriteTimeout != 9*time.Second {
t.Fatalf("unexpected redis timeout config: %#v", cfg.Redis)
}
if cfg.Redis.PoolSize != 32 || cfg.Redis.MinIdleConns != 4 || cfg.Redis.MaxRetries != 5 || cfg.Redis.PoolTimeout != 6*time.Second {
t.Fatalf("unexpected redis pool config: %#v", cfg.Redis)
}
if cfg.Redis.TLSConfig() == nil {
t.Fatalf("expected redis TLS config")
}
if cfg.Cache.Driver != "memory" || cfg.Cache.Namespace != "tenant-cache" || cfg.Cache.JitterRatio != 0.25 {
t.Fatalf("unexpected cache base config: %#v", cfg.Cache)
}
if cfg.Cache.MetricsEnabled {
t.Fatalf("expected cache metrics override false")
}
if !cfg.Cache.AsyncFillEnabled || cfg.Cache.AsyncFillWorkers != 3 || cfg.Cache.AsyncFillBuffer != 64 || cfg.Cache.AsyncFillTimeout != 1500*time.Millisecond {
t.Fatalf("unexpected cache async config: %#v", cfg.Cache)
}
if !cfg.Cache.L1Enabled || cfg.Cache.L1TTL != 5*time.Second || cfg.Cache.DeleteScanCount != 1200 {
t.Fatalf("unexpected cache l1/delete config: %#v", cfg.Cache)
}
}
func TestLoadAppliesMediaSupplyMeijiequanAccountOverrides(t *testing.T) {
t.Setenv("MEIJIEQUAN_USERNAME", " 17788409108 ")
t.Setenv("MEIJIEQUAN_PASSWORD", " env-password ")
t.Setenv("MEIJIEQUAN_SYNC_PAGE_DELAY", "1500ms")
t.Setenv("MEIJIEQUAN_SEARCH_OPTIONS_TTL", "2h")
t.Setenv("MEIJIEQUAN_UPSTREAM_MIN_INTERVAL", "3s")
t.Setenv("MEDIA_SUPPLY_BACKLINK_SYNC_INTERVAL", "15m")
t.Setenv("MEDIA_SUPPLY_BACKLINK_BATCH_SIZE", "12")
t.Setenv("MEIJIEQUAN_PUBLISHED_PAGE_SIZE", "30")
t.Setenv("MEIJIEQUAN_PUBLISHED_MAX_PAGES", "4")
configPath := writeTestConfig(t, `
media_supply:
meijiequan:
base_url: "http://www.meijiequan.com/"
user_id: " 962 "
username: config-user
password: config-password
`)
cfg, err := Load(configPath)
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if cfg.MediaSupply.Meijiequan.BaseURL != "http://www.meijiequan.com" ||
cfg.MediaSupply.Meijiequan.UserID != "962" ||
cfg.MediaSupply.Meijiequan.Username != "17788409108" ||
cfg.MediaSupply.Meijiequan.Password != "env-password" ||
cfg.MediaSupply.Meijiequan.SyncPageDelay != 1500*time.Millisecond ||
cfg.MediaSupply.Meijiequan.SearchOptionsTTL != 2*time.Hour ||
cfg.MediaSupply.Meijiequan.UpstreamMinInterval != 3*time.Second ||
cfg.MediaSupply.Worker.BacklinkSyncInterval != 15*time.Minute ||
cfg.MediaSupply.Worker.BacklinkBatchSize != 12 ||
cfg.MediaSupply.Meijiequan.PublishedPageSize != 30 ||
cfg.MediaSupply.Meijiequan.PublishedMaxPages != 4 {
t.Fatalf("unexpected meijiequan config: %#v", cfg.MediaSupply.Meijiequan)
}
}
func TestLoadPrefersEnvSchedulerMetricsSettings(t *testing.T) {
t.Setenv("SCHEDULER_HTTP_HOST", "0.0.0.0")
t.Setenv("SCHEDULER_INTERNAL_METRICS_TOKEN", "env-metrics-token")
configPath := writeTestConfig(t, `
scheduler:
http_host: 127.0.0.1
internal_metrics_token: config-token
`)
cfg, err := Load(configPath)
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if cfg.Scheduler.HTTPHost != "0.0.0.0" {
t.Fatalf("expected env scheduler http host, got %q", cfg.Scheduler.HTTPHost)
}
if cfg.Scheduler.InternalMetricsToken != "env-metrics-token" {
t.Fatalf("expected env scheduler metrics token, got %q", cfg.Scheduler.InternalMetricsToken)
}
}
func TestLoadPrefersEnvJWTSettings(t *testing.T) {
t.Setenv("JWT_SECRET", "env-jwt-secret")
t.Setenv("JWT_ACCESS_TTL", "20m")
t.Setenv("JWT_REFRESH_TTL", "720h")
configPath := writeTestConfig(t, `
jwt:
secret: config-jwt-secret
access_ttl: 15m
refresh_ttl: 168h
`)
cfg, err := Load(configPath)
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if cfg.JWT.Secret != "env-jwt-secret" {
t.Fatalf("expected env jwt secret, got %q", cfg.JWT.Secret)
}
if cfg.JWT.AccessTTL != 20*time.Minute {
t.Fatalf("expected env jwt access ttl 20m, got %s", cfg.JWT.AccessTTL)
}
if cfg.JWT.RefreshTTL != 720*time.Hour {
t.Fatalf("expected env jwt refresh ttl 720h, got %s", cfg.JWT.RefreshTTL)
}
}
func TestLoadPrefersEnvPasswordCipherSettings(t *testing.T) {
t.Setenv("AUTH_PASSWORD_CIPHER_ENABLED", "true")
t.Setenv("AUTH_PASSWORD_CIPHER_KEY_ID", "env-key")
t.Setenv("AUTH_PASSWORD_CIPHER_PRIVATE_KEY_PEM", "pem-value")
configPath := writeTestConfig(t, `
auth:
password_cipher:
enabled: false
key_id: config-key
private_key_pem: config-pem
`)
cfg, err := Load(configPath)
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if !cfg.Auth.PasswordCipher.Enabled {
t.Fatalf("expected env password cipher enabled")
}
if cfg.Auth.PasswordCipher.KeyID != "env-key" {
t.Fatalf("expected env key id, got %q", cfg.Auth.PasswordCipher.KeyID)
}
if cfg.Auth.PasswordCipher.PrivateKeyPEM != "pem-value" {
t.Fatalf("expected env private key pem, got %q", cfg.Auth.PasswordCipher.PrivateKeyPEM)
}
}
func TestLoadAppliesMembershipDefaults(t *testing.T) {
configPath := writeTestConfig(t, `
membership: {}
`)
cfg, err := Load(configPath)
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if cfg.Membership.DefaultPlanCode != "free" {
t.Fatalf("expected default plan code free, got %q", cfg.Membership.DefaultPlanCode)
}
if len(cfg.Membership.Plans) != 3 {
t.Fatalf("expected 3 default plans, got %d", len(cfg.Membership.Plans))
}
free, ok := cfg.Membership.FindPlan("free")
if !ok {
t.Fatalf("expected free plan to exist")
}
if free.ArticleGeneration != 3 {
t.Fatalf("expected free article generation 3, got %d", free.ArticleGeneration)
}
if free.ImageStorageBytes != 10*1024*1024 {
t.Fatalf("expected free image storage 10MB, got %d", free.ImageStorageBytes)
}
if free.CompanyLimit != 1 {
t.Fatalf("expected free company limit 1, got %d", free.CompanyLimit)
}
if free.SubscriptionDuration != 72*time.Hour {
t.Fatalf("expected free subscription duration 72h, got %s", free.SubscriptionDuration)
}
if !free.ContactAdminOnExpiry {
t.Fatalf("expected free plan to require admin contact on expiry")
}
pro, ok := cfg.Membership.FindPlan("pro")
if !ok {
t.Fatalf("expected pro plan to exist")
}
if pro.ArticleGeneration != 400 {
t.Fatalf("expected pro article generation 400, got %d", pro.ArticleGeneration)
}
if pro.AIPointsMonthly != 1500 {
t.Fatalf("expected pro ai points 1500, got %d", pro.AIPointsMonthly)
}
if pro.AIPointBaseChars != 1000 {
t.Fatalf("expected pro ai point base chars 1000, got %d", pro.AIPointBaseChars)
}
if pro.CompanyLimit != 2 {
t.Fatalf("expected pro company limit 2, got %d", pro.CompanyLimit)
}
}
func TestLoadMergesLocalOverrideAndResolvesEnvPlaceholders(t *testing.T) {
t.Setenv("CONFIG_TEST_LLM_KEY", "placeholder-key")
t.Setenv("LLM_API_KEY", "")
dir := t.TempDir()
configPath := filepath.Join(dir, "config.yaml")
localPath := filepath.Join(dir, "config.local.yaml")
writeFile(t, configPath, `
llm:
provider: ark
api_key: "${CONFIG_TEST_LLM_KEY:missing}"
max_output_tokens: 1000
generation:
stream_enabled: false
`)
writeFile(t, localPath, `
generation:
stream_enabled: true
`)
cfg, err := Load(configPath)
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if cfg.LLM.APIKey != "placeholder-key" {
t.Fatalf("expected placeholder env api key, got %q", cfg.LLM.APIKey)
}
if !cfg.Generation.StreamEnabled {
t.Fatalf("expected local override to enable generation stream")
}
}
func TestLoadAppliesGenerationRecoveryDefaults(t *testing.T) {
configPath := writeTestConfig(t, `
generation:
article_timeout: 90s
`)
cfg, err := Load(configPath)
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if cfg.Generation.QueueSize != 128 {
t.Fatalf("expected default queue size 128, got %d", cfg.Generation.QueueSize)
}
if cfg.Generation.WorkerConcurrency != 1 {
t.Fatalf("expected default worker concurrency 1, got %d", cfg.Generation.WorkerConcurrency)
}
if cfg.Generation.TaskLeaseTTL != 3*time.Minute+30*time.Second {
t.Fatalf("expected default task lease ttl article_timeout+2m, got %s", cfg.Generation.TaskLeaseTTL)
}
if cfg.Generation.TaskRecoveryInterval != time.Minute {
t.Fatalf("expected default task recovery interval 1m, got %s", cfg.Generation.TaskRecoveryInterval)
}
if cfg.Generation.TaskRecoveryTimeout != 30*time.Second {
t.Fatalf("expected default task recovery timeout 30s, got %s", cfg.Generation.TaskRecoveryTimeout)
}
if cfg.Generation.TaskRecoveryBatchSize != 100 {
t.Fatalf("expected default task recovery batch size 100, got %d", cfg.Generation.TaskRecoveryBatchSize)
}
if cfg.Generation.TaskQueuedStaleAfter != 2*time.Minute {
t.Fatalf("expected default queued stale after 2m, got %s", cfg.Generation.TaskQueuedStaleAfter)
}
if cfg.Generation.TaskMaxAttempts != 3 {
t.Fatalf("expected default task max attempts 3, got %d", cfg.Generation.TaskMaxAttempts)
}
if cfg.Generation.TaskStateCheckInterval != 5*time.Minute {
t.Fatalf("expected default task state check interval 5m, got %s", cfg.Generation.TaskStateCheckInterval)
}
if cfg.Generation.TaskStateCheckTimeout != 10*time.Second {
t.Fatalf("expected default task state check timeout 10s, got %s", cfg.Generation.TaskStateCheckTimeout)
}
if cfg.Generation.TaskStateCheckBatchSize != 100 {
t.Fatalf("expected default task state check batch size 100, got %d", cfg.Generation.TaskStateCheckBatchSize)
}
if cfg.Generation.TaskStateCheckLookback != 24*time.Hour {
t.Fatalf("expected default task state check lookback 24h, got %s", cfg.Generation.TaskStateCheckLookback)
}
}
func TestLoadAppliesGenerationRecoveryOverrides(t *testing.T) {
configPath := writeTestConfig(t, `
generation:
queue_size: 64
worker_concurrency: 4
article_timeout: 5m
task_lease_ttl: 7m
task_recovery_interval: 15s
task_recovery_timeout: 10s
task_recovery_batch_size: 12
task_queued_stale_after: 45s
task_max_attempts: 5
task_state_check_interval: 9m
task_state_check_timeout: 11s
task_state_check_batch_size: 13
task_state_check_lookback: 2h
`)
cfg, err := Load(configPath)
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if cfg.Generation.QueueSize != 64 || cfg.Generation.WorkerConcurrency != 4 || cfg.Generation.ArticleTimeout != 5*time.Minute {
t.Fatalf("unexpected generation base config: %#v", cfg.Generation)
}
if cfg.Generation.TaskLeaseTTL != 7*time.Minute ||
cfg.Generation.TaskRecoveryInterval != 15*time.Second ||
cfg.Generation.TaskRecoveryTimeout != 10*time.Second ||
cfg.Generation.TaskRecoveryBatchSize != 12 ||
cfg.Generation.TaskQueuedStaleAfter != 45*time.Second ||
cfg.Generation.TaskMaxAttempts != 5 ||
cfg.Generation.TaskStateCheckInterval != 9*time.Minute ||
cfg.Generation.TaskStateCheckTimeout != 11*time.Second ||
cfg.Generation.TaskStateCheckBatchSize != 13 ||
cfg.Generation.TaskStateCheckLookback != 2*time.Hour {
t.Fatalf("unexpected generation recovery config: %#v", cfg.Generation)
}
}
func TestLoadAppliesGenerationStateCheckEnvOverrides(t *testing.T) {
t.Setenv("GENERATION_TASK_STATE_CHECK_INTERVAL", "3m")
t.Setenv("GENERATION_TASK_STATE_CHECK_TIMEOUT", "4s")
t.Setenv("GENERATION_TASK_STATE_CHECK_BATCH_SIZE", "17")
t.Setenv("GENERATION_TASK_STATE_CHECK_LOOKBACK", "6h")
configPath := writeTestConfig(t, `
generation: {}
`)
cfg, err := Load(configPath)
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if cfg.Generation.TaskStateCheckInterval != 3*time.Minute ||
cfg.Generation.TaskStateCheckTimeout != 4*time.Second ||
cfg.Generation.TaskStateCheckBatchSize != 17 ||
cfg.Generation.TaskStateCheckLookback != 6*time.Hour {
t.Fatalf("unexpected generation state check env config: %#v", cfg.Generation)
}
}
func TestLoadAppliesBrowserFetchDefaultsAndOverrides(t *testing.T) {
configPath := writeTestConfig(t, `
browser_fetch:
enabled: true
service_url: " http://browser-fetch:8082/ "
token: " token "
allowed_domains:
- " zhuanlan.zhihu.com "
- ""
trigger_status:
- 403
- 403
- 500
`)
cfg, err := Load(configPath)
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if !cfg.BrowserFetch.Enabled {
t.Fatalf("expected browser fetch enabled")
}
if cfg.BrowserFetch.Provider != "lightpanda" {
t.Fatalf("expected default provider lightpanda, got %q", cfg.BrowserFetch.Provider)
}
if cfg.BrowserFetch.ServiceURL != "http://browser-fetch:8082" {
t.Fatalf("expected normalized service url, got %q", cfg.BrowserFetch.ServiceURL)
}
if cfg.BrowserFetch.Token != "token" {
t.Fatalf("expected normalized token, got %q", cfg.BrowserFetch.Token)
}
if cfg.BrowserFetch.QueueSize != 64 ||
cfg.BrowserFetch.WorkerConcurrency != 1 ||
cfg.BrowserFetch.RequestTimeout != 45*time.Second ||
cfg.BrowserFetch.CacheTTL != 6*time.Hour {
t.Fatalf("unexpected browser fetch defaults: %#v", cfg.BrowserFetch)
}
if len(cfg.BrowserFetch.AllowedDomains) != 1 || cfg.BrowserFetch.AllowedDomains[0] != "zhuanlan.zhihu.com" {
t.Fatalf("unexpected allowed domains: %#v", cfg.BrowserFetch.AllowedDomains)
}
if len(cfg.BrowserFetch.TriggerStatus) != 2 ||
cfg.BrowserFetch.TriggerStatus[0] != 403 ||
cfg.BrowserFetch.TriggerStatus[1] != 500 {
t.Fatalf("unexpected trigger status: %#v", cfg.BrowserFetch.TriggerStatus)
}
}
func TestLoadAppliesBrowserFetchEnvOverrides(t *testing.T) {
t.Setenv("BROWSER_FETCH_ENABLED", "true")
t.Setenv("BROWSER_FETCH_PROVIDER", "lightpanda_service")
t.Setenv("BROWSER_FETCH_SERVICE_URL", "http://env-browser-fetch:8082/")
t.Setenv("BROWSER_FETCH_TOKEN", "env-token")
t.Setenv("BROWSER_FETCH_QUEUE_SIZE", "11")
t.Setenv("BROWSER_FETCH_WORKER_CONCURRENCY", "3")
t.Setenv("BROWSER_FETCH_REQUEST_TIMEOUT", "12s")
t.Setenv("BROWSER_FETCH_CACHE_TTL", "30m")
t.Setenv("BROWSER_FETCH_ALLOWED_DOMAINS", "zhihu.com,example.com")
t.Setenv("BROWSER_FETCH_TRIGGER_STATUS", "403,429,broken,503")
configPath := writeTestConfig(t, `
browser_fetch: {}
`)
cfg, err := Load(configPath)
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if !cfg.BrowserFetch.Enabled ||
cfg.BrowserFetch.Provider != "lightpanda_service" ||
cfg.BrowserFetch.ServiceURL != "http://env-browser-fetch:8082" ||
cfg.BrowserFetch.Token != "env-token" ||
cfg.BrowserFetch.QueueSize != 11 ||
cfg.BrowserFetch.WorkerConcurrency != 3 ||
cfg.BrowserFetch.RequestTimeout != 12*time.Second ||
cfg.BrowserFetch.CacheTTL != 30*time.Minute {
t.Fatalf("unexpected browser fetch env config: %#v", cfg.BrowserFetch)
}
if got := cfg.BrowserFetch.AllowedDomains; len(got) != 2 || got[0] != "zhihu.com" || got[1] != "example.com" {
t.Fatalf("unexpected env allowed domains: %#v", got)
}
if got := cfg.BrowserFetch.TriggerStatus; len(got) != 3 || got[0] != 403 || got[1] != 429 || got[2] != 503 {
t.Fatalf("unexpected env trigger status: %#v", got)
}
}
func TestStoreReloadsConfigAndKeepsPreviousOnInvalidYAML(t *testing.T) {
configPath := writeTestConfig(t, `
jwt:
secret: first
access_ttl: 15m
refresh_ttl: 720h
`)
store, err := NewStore(configPath)
if err != nil {
t.Fatalf("NewStore() error = %v", err)
}
defer store.Close()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
events := make(chan ReloadEvent, 4)
go func() {
_ = store.Watch(ctx, nil, func(event ReloadEvent) {
events <- event
})
}()
waitForStoreWatch(t, store)
time.Sleep(100 * time.Millisecond)
writeFile(t, configPath, `
jwt:
secret: second
access_ttl: 20m
refresh_ttl: 720h
`)
event := waitForReloadEvent(t, events)
if event.Current.JWT.Secret != "second" {
t.Fatalf("expected reloaded jwt secret second, got %q", event.Current.JWT.Secret)
}
if store.Current().JWT.AccessTTL != 20*time.Minute {
t.Fatalf("expected store access ttl 20m, got %s", store.Current().JWT.AccessTTL)
}
writeFile(t, configPath, "jwt:\n secret: [broken\n")
time.Sleep(300 * time.Millisecond)
if store.Current().JWT.Secret != "second" {
t.Fatalf("expected invalid yaml to keep previous config, got %q", store.Current().JWT.Secret)
}
}
func TestStoreReloadsWhenLocalOverrideIsCreated(t *testing.T) {
dir := t.TempDir()
configPath := filepath.Join(dir, "config.yaml")
localPath := filepath.Join(dir, "config.local.yaml")
writeFile(t, configPath, `
generation:
stream_enabled: false
`)
store, err := NewStore(configPath)
if err != nil {
t.Fatalf("NewStore() error = %v", err)
}
defer store.Close()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
events := make(chan ReloadEvent, 4)
go func() {
_ = store.Watch(ctx, nil, func(event ReloadEvent) {
events <- event
})
}()
waitForStoreWatch(t, store)
time.Sleep(100 * time.Millisecond)
writeFile(t, localPath, `
generation:
stream_enabled: true
`)
event := waitForReloadEvent(t, events)
if !event.Current.Generation.StreamEnabled {
t.Fatalf("expected created local override to enable stream")
}
}
func writeTestConfig(t *testing.T, body string) string {
t.Helper()
dir := t.TempDir()
path := filepath.Join(dir, "config.yaml")
writeFile(t, path, body)
return path
}
func writeFile(t *testing.T, path, body string) {
t.Helper()
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatalf("write file %s: %v", path, err)
}
}
func waitForStoreWatch(t *testing.T, store *Store) {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if store.runtime != nil && store.runtime.Value("").Load() != nil {
return
}
time.Sleep(10 * time.Millisecond)
}
t.Fatalf("config store watcher did not start")
}
func waitForReloadEvent(t *testing.T, events <-chan ReloadEvent) ReloadEvent {
t.Helper()
timeout := time.After(3 * time.Second)
for {
select {
case event := <-events:
if event.Current != nil {
return event
}
case <-timeout:
t.Fatalf("timed out waiting for reload event")
}
}
}