package config import ( "path/filepath" "testing" "time" ) func writeConfig(t *testing.T, body string) string { t.Helper() p := filepath.Join(t.TempDir(), "mavend.json") if err := writeFile(t, p, body); err != nil { t.Fatalf("write config: %v", err) } return p } func TestLoadDefaults(t *testing.T) { p := writeConfig(t, `{}`) c, err := Load(p) if err != nil { t.Fatalf("Load: %v", err) } if time.Duration(c.TickInterval) != DefaultTickInterval { t.Errorf("TickInterval default = %v, want %v", c.TickInterval, DefaultTickInterval) } if time.Duration(c.RepeatInterval) != DefaultRepeatInterval { t.Errorf("RepeatInterval default = %v, want %v", c.RepeatInterval, DefaultRepeatInterval) } if c.DBPath == "" { t.Error("DBPath default not applied") } if c.SocketPath == "" { t.Error("SocketPath default not applied") } } // Nudges come from templates unless the config says otherwise. func TestPhraserLLMNudgesDefaultsOff(t *testing.T) { p := writeConfig(t, `{"phraser":{"model_path":"/tmp/m.gguf"}}`) c, err := Load(p) if err != nil { t.Fatalf("Load: %v", err) } if c.Phraser.LLMNudges { t.Error("llm_nudges defaults on; templates must be the default") } p = writeConfig(t, `{"phraser":{"model_path":"/tmp/m.gguf","llm_nudges":true}}`) c, err = Load(p) if err != nil { t.Fatalf("Load: %v", err) } if !c.Phraser.LLMNudges { t.Error("llm_nudges:true did not parse") } } func TestLoadDurationsParse(t *testing.T) { p := writeConfig(t, `{"tick_interval":"90s","repeat_interval":"10m"}`) c, err := Load(p) if err != nil { t.Fatalf("Load: %v", err) } if time.Duration(c.TickInterval) != 90*time.Second { t.Errorf("TickInterval = %v, want 90s", c.TickInterval) } if time.Duration(c.RepeatInterval) != 10*time.Minute { t.Errorf("RepeatInterval = %v, want 10m", c.RepeatInterval) } } func TestLoadBadDurationRejected(t *testing.T) { p := writeConfig(t, `{"tick_interval":"not-a-duration"}`) if _, err := Load(p); err == nil { t.Fatal("Load succeeded for a bad duration; want error") } } func TestLoadMissingFile(t *testing.T) { p := filepath.Join(t.TempDir(), "nonexistent.json") if _, err := Load(p); err == nil { t.Fatal("Load succeeded for a missing file; want error") } } func TestVoiceEnabledRequiresBind(t *testing.T) { // enabled=true without bind is refused — the voice surface can't // default a bind (127.0.0.1 too relaxed for production, a wg addr is // the user's). surfacing the gap explicitly beats an idle listener. p := writeConfig(t, `{"voice":{"enabled":true}}`) if _, err := Load(p); err == nil { t.Fatal("Load succeeded for voice.enabled=true with no bind; want error") } } func TestVoiceEnabledWithBindOK(t *testing.T) { // voice surface fully configured — accepted (the daemon wires Stub tts + // voicesink; no models on disk required). p := writeConfig(t, `{"voice":{"enabled":true,"bind":"127.0.0.1:9100"}}`) if _, err := Load(p); err != nil { t.Fatalf("Load: %v", err) } } func TestEmbedderAllPathsSetOK(t *testing.T) { p := writeConfig(t, `{ "voice": { "enabled": true, "bind": "127.0.0.1:9100", "embedder": { "model_path": "m.onnx", "tokenizer_path": "t.json", "lib_path": "l.so" } } }`) if _, err := Load(p); err != nil { t.Fatalf("Load: %v", err) } } func TestEmbedderPartialConfigRejected(t *testing.T) { tests := []struct { name string json string }{ {"missing model_path", `{"voice":{"enabled":true,"bind":"127.0.0.1:9100","embedder":{"tokenizer_path":"t.json","lib_path":"l.so"}}}`}, {"missing tokenizer_path", `{"voice":{"enabled":true,"bind":"127.0.0.1:9100","embedder":{"model_path":"m.onnx","lib_path":"l.so"}}}`}, {"missing lib_path", `{"voice":{"enabled":true,"bind":"127.0.0.1:9100","embedder":{"model_path":"m.onnx","tokenizer_path":"t.json"}}}`}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { p := writeConfig(t, tt.json) if _, err := Load(p); err == nil { t.Fatal("Load succeeded for partial embedder; want error") } }) } } func TestEmbedderNilOK(t *testing.T) { // voice block without embedder → ok (HashEmbedder floor) p := writeConfig(t, `{"voice":{"enabled":true,"bind":"127.0.0.1:9100"}}`) if _, err := Load(p); err != nil { t.Fatalf("Load: %v", err) } } func TestVoicePresentDisabledOK(t *testing.T) { // voice block present but not enabled — acceptable (the listener stays // down; the routing table's ChannelVoice selections drop). p := writeConfig(t, `{"voice":{"enabled":false}}`) if _, err := Load(p); err != nil { t.Fatalf("Load: %v", err) } } func TestVoiceSttTtsConfigParsed(t *testing.T) { // the stt/tts worker sub-blocks parse + remember socket/lang. p := writeConfig(t, `{ "voice": { "enabled": true, "bind": "127.0.0.1:9100", "stt": {"socket": "/tmp/stt.sock", "lang": "ru"}, "tts": {"socket": "/tmp/tts.sock", "lang": "ru", "voice": "natasha"} } }`) c, err := Load(p) if err != nil { t.Fatalf("Load: %v", err) } if c.Voice.Stt == nil || c.Voice.Stt.Socket != "/tmp/stt.sock" { t.Fatalf("Stt config not parsed: %+v", c.Voice) } if c.Voice.Tts == nil || c.Voice.Tts.Socket != "/tmp/tts.sock" || c.Voice.Tts.Voice != "natasha" { t.Fatalf("Tts config not parsed: %+v", c.Voice) } } func TestWeatherConfig(t *testing.T) { // Weather block with provider + default location → OK p := writeConfig(t, `{"voice":{"enabled":true,"bind":"127.0.0.1:9100","weather":{"provider":"open-meteo","default_location":"Moscow"}}}`) if _, err := Load(p); err != nil { t.Fatalf("Load with weather config: %v", err) } } func TestWeatherConfigNilOK(t *testing.T) { // No weather block → OK (stub) p := writeConfig(t, `{"voice":{"enabled":true,"bind":"127.0.0.1:9100"}}`) if _, err := Load(p); err != nil { t.Fatalf("Load without weather config: %v", err) } } func TestLLMRouterDefaultsOn(t *testing.T) { p := writeConfig(t, `{"voice":{"enabled":true,"bind":"127.0.0.1:9100"}}`) c, err := Load(p) if err != nil { t.Fatalf("Load: %v", err) } if !c.Voice.UseLLMRouter() { t.Error("voice.llm_router absent should mean on") } } // Missing and explicitly false must not mean the same thing. func TestLLMRouterExplicitFalseTurnsItOff(t *testing.T) { p := writeConfig(t, `{"voice":{"enabled":true,"bind":"127.0.0.1:9100","llm_router":false}}`) c, err := Load(p) if err != nil { t.Fatalf("Load: %v", err) } if c.Voice.UseLLMRouter() { t.Error("voice.llm_router false should turn it off") } } func TestLLMRouterRead(t *testing.T) { p := writeConfig(t, `{"voice":{"enabled":true,"bind":"127.0.0.1:9100","llm_router":true}}`) c, err := Load(p) if err != nil { t.Fatalf("Load: %v", err) } if !c.Voice.UseLLMRouter() { t.Error("voice.llm_router true was not read") } } func TestDurationRoundTrip(t *testing.T) { d := Duration(15 * time.Minute) b, err := d.MarshalJSON() if err != nil { t.Fatalf("MarshalJSON: %v", err) } if got, want := string(b), `"15m0s"`; got != want { t.Errorf("MarshalJSON = %s, want %s", got, want) } var d2 Duration if err := d2.UnmarshalJSON(b); err != nil { t.Fatalf("UnmarshalJSON: %v", err) } if d2 != d { t.Errorf("round-trip = %v, want %v", d2, d) } } // Both new opt-in capabilities follow the same rule: absent block ⇒ nil ⇒ the // behaviour does not exist. Presence is the enable act, so a bare `{}` block is // valid and gets the defaults filled in. func TestOptInBlocksAbsentStayNil(t *testing.T) { c, err := Load(writeConfig(t, `{}`)) if err != nil { t.Fatalf("Load: %v", err) } if c.PatternProposals != nil { t.Errorf("pattern_proposals absent but got %+v", c.PatternProposals) } if c.PatternProposals.AnnounceProposals() { t.Error("AnnounceProposals() true with no config block") } if c.MemoryEval != nil { t.Errorf("memory_eval absent but got %+v", c.MemoryEval) } } func TestOptInBlocksGetDefaultsWhenPresent(t *testing.T) { c, err := Load(writeConfig(t, `{"pattern_proposals":{"notify":true},"memory_eval":{}}`)) if err != nil { t.Fatalf("Load: %v", err) } if !c.PatternProposals.AnnounceProposals() { t.Error("notify:true did not enable announcements") } if time.Duration(c.PatternProposals.Cooldown) != DefaultProposalCooldown { t.Errorf("proposal cooldown = %v, want %v", c.PatternProposals.Cooldown, DefaultProposalCooldown) } if time.Duration(c.MemoryEval.Interval) != DefaultMemoryEvalInterval { t.Errorf("memory eval interval = %v, want %v", c.MemoryEval.Interval, DefaultMemoryEvalInterval) } } // Notify is off even when the block exists — the block is where you tune it, // notify:true is the act that lets her speak. func TestPatternProposalNotifyDefaultsOff(t *testing.T) { c, err := Load(writeConfig(t, `{"pattern_proposals":{"cooldown":"6h"}}`)) if err != nil { t.Fatalf("Load: %v", err) } if c.PatternProposals.AnnounceProposals() { t.Error("notify defaulted to on") } if time.Duration(c.PatternProposals.Cooldown) != 6*time.Hour { t.Errorf("cooldown = %v, want 6h", c.PatternProposals.Cooldown) } } // TestSwapModelsAbsentMeansOff — the swap capability does not exist unless the // operator lists the models he allows (Vikunja #250). func TestSwapModelsAbsentMeansOff(t *testing.T) { c, err := Load(writeConfig(t, `{"phraser": {"model_path": "/m/qwen.gguf"}}`)) if err != nil { t.Fatalf("Load: %v", err) } if len(c.Phraser.SwapModels) != 0 { t.Errorf("swap_models = %v; want empty when unconfigured", c.Phraser.SwapModels) } } func TestSwapModelsParsedAndMustBeAbsolute(t *testing.T) { c, err := Load(writeConfig(t, `{"phraser": { "model_path": "/m/qwen.gguf", "swap_models": ["/m/qwen.gguf", "/m/qwen-cpt.gguf"] }}`)) if err != nil { t.Fatalf("Load: %v", err) } if len(c.Phraser.SwapModels) != 2 { t.Fatalf("swap_models = %v; want 2 entries", c.Phraser.SwapModels) } // A relative entry would resolve against the daemon's cwd, not the operator's. if _, err := Load(writeConfig(t, `{"phraser": { "model_path": "/m/qwen.gguf", "swap_models": ["models/llm/qwen.gguf"] }}`)); err == nil { t.Error("Load accepted a relative swap_models entry; want a startup failure") } } // TestUpdateBlockAbsentMeansOff — mavend never updates itself; the block only // exists so cmd/mavupdate can find the deployment it is asked to update // (Vikunja #249). Absent is the normal state. func TestUpdateBlockAbsentMeansOff(t *testing.T) { c, err := Load(writeConfig(t, `{}`)) if err != nil { t.Fatalf("Load: %v", err) } if c.Update != nil { t.Errorf("update = %+v; want nil when unconfigured", c.Update) } } func TestUpdateBlockValidatedAtStartup(t *testing.T) { good := `{"update": { "source_dir": "/srv/maven", "install_dir": "/srv/maven", "snapshot_dir": "/var/lib/maven/snapshots", "binaries": ["mavend", "mavweb"], "restart_cmd": ["docker", "compose", "up", "-d", "--build", "mavend"], "health_socket": "/run/maven/mavend.sock", "source_rollback": "git" }}` c, err := Load(writeConfig(t, good)) if err != nil { t.Fatalf("Load: %v", err) } if c.Update == nil || len(c.Update.Binaries) != 2 { t.Fatalf("update block = %+v; want it parsed", c.Update) } // A block with no health check cannot detect its own failure, so it cannot // roll back — refused at load, not halfway through a deploy. noHealth := `{"update": { "source_dir": "/srv/maven", "install_dir": "/srv/maven", "snapshot_dir": "/var/lib/maven/snapshots", "binaries": ["mavend"], "restart_cmd": ["true"] }}` if _, err := Load(writeConfig(t, noHealth)); err == nil { t.Error("Load accepted an update block with no health_socket") } } // A kiwix block with no address, or no book, has nothing to search. // kiwix-serve answers 400 to an empty books.name, so the block is dropped here // rather than left to fail one query at a time. func TestNormaliseDropsIncompleteKiwix(t *testing.T) { for _, tc := range []struct { name string in *KiwixConfig }{ {"no url", &KiwixConfig{Book: "wikipedia_en_all_maxi"}}, {"no book", &KiwixConfig{URL: "http://kiwix:8080"}}, {"blank url", &KiwixConfig{URL: " ", Book: "b"}}, } { t.Run(tc.name, func(t *testing.T) { c := &Config{Kiwix: tc.in} c.applyDefaults() if c.Kiwix != nil { t.Errorf("kept an unusable kiwix block: %+v", c.Kiwix) } }) } } func TestNormaliseFillsKiwixDefaults(t *testing.T) { c := &Config{Kiwix: &KiwixConfig{URL: "http://kiwix:8080", Book: "b"}} c.applyDefaults() if c.Kiwix == nil { t.Fatal("dropped a complete kiwix block") } if c.Kiwix.MaxResults != DefaultKiwixResults { t.Errorf("MaxResults = %d, want %d", c.Kiwix.MaxResults, DefaultKiwixResults) } if c.Kiwix.SnippetRunes != DefaultKiwixSnippetRunes { t.Errorf("SnippetRunes = %d, want %d", c.Kiwix.SnippetRunes, DefaultKiwixSnippetRunes) } // Rewriting is on unless it is turned off: an English ZIM searched with a // Russian sentence matches nothing, so the useful default is the on one. if !c.Kiwix.RewriteEnabled() { t.Error("rewriting defaulted to off") } off := false c.Kiwix.Rewrite = &off if c.Kiwix.RewriteEnabled() { t.Error("rewrite: false was not honoured") } } // A workstation with no address is not a workstation. The unconfigured deploy // must be indistinguishable from today, so the block is dropped rather than // left to fail one probe at a time. func TestNormaliseDropsAddresslessWorkstation(t *testing.T) { for _, tc := range []struct { name string in *WorkstationConfig }{ {"no url", &WorkstationConfig{Probe: Duration(time.Second)}}, {"blank url", &WorkstationConfig{URL: " "}}, } { t.Run(tc.name, func(t *testing.T) { c := &Config{Workstation: tc.in} c.applyDefaults() if c.Workstation != nil { t.Errorf("kept an unusable workstation block: %+v", c.Workstation) } }) } } // The health endpoint defaults to the supervisor's, not llama-server's: mavgpud // answers 503 while the card is held, and that refusal is the whole signal. func TestNormaliseFillsWorkstationDefaults(t *testing.T) { c := &Config{Workstation: &WorkstationConfig{URL: "http://192.168.1.105:8080/"}} c.applyDefaults() if c.Workstation == nil { t.Fatal("dropped a usable workstation block") } if got, want := c.Workstation.Health, "http://192.168.1.105:8080/health"; got != want { t.Errorf("Health = %q, want %q", got, want) } if time.Duration(c.Workstation.Probe) != DefaultWorkstationProbe { t.Errorf("Probe = %s, want %s", time.Duration(c.Workstation.Probe), DefaultWorkstationProbe) } if time.Duration(c.Workstation.Timeout) != DefaultWorkstationTimeout { t.Errorf("Timeout = %s, want %s", time.Duration(c.Workstation.Timeout), DefaultWorkstationTimeout) } } // An explicit health URL is left alone: the supervisor may sit behind something // that does not put /health at the root. func TestNormaliseKeepsExplicitWorkstationHealth(t *testing.T) { c := &Config{Workstation: &WorkstationConfig{ URL: "http://192.168.1.105:8080", Health: "http://192.168.1.105:9000/ready", }} c.applyDefaults() if got, want := c.Workstation.Health, "http://192.168.1.105:9000/ready"; got != want { t.Errorf("Health = %q, want %q", got, want) } } func TestTelegramIntakeRefusesNamedChat(t *testing.T) { // The push half accepts an @channelusername and the intake half cannot use // one, so a box with both boots clean and answers nothing. Refuse the // config instead. p := writeConfig(t, `{"telegram":{"bot_token":"t","chat_id":"@maven","intake":true}}`) if _, err := Load(p); err == nil { t.Fatal("Load succeeded for intake with an @-name chat id; want error") } } func TestTelegramNamedChatOKWithoutIntake(t *testing.T) { // Push-only is what the @-name is for, so nothing changes for a box that // never turned intake on. p := writeConfig(t, `{"telegram":{"bot_token":"t","chat_id":"@maven"}}`) if _, err := Load(p); err != nil { t.Fatalf("Load: %v", err) } } func TestTelegramIntakeAcceptsNumericChat(t *testing.T) { p := writeConfig(t, `{"telegram":{"bot_token":"t","chat_id":"-1001234567890","intake":true}}`) if _, err := Load(p); err != nil { t.Fatalf("Load: %v", err) } }