2a3701d012
The docker-shaped update block in TestUpdateBlockValidatedAtStartup has source_dir equal to install_dir and no source_rollback, which is exactly the deployment the new validation refuses. The fixture is meant to be the good case, so it now says how the source is rolled back. Found in review of #69.
370 lines
11 KiB
Go
370 lines
11 KiB
Go
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")
|
|
}
|
|
}
|