35c6ff5a71
Persist reminder presentations and retry state, atomically complete collapsed deliveries, fall back across away reaches, and block permanent failures visibly (V-715, V-678). Fail closed when enabled integrations lack credentials and keep remote arms explicitly dark (V-691). Give mavweb one sanitized, request-correlated error contract (V-689). Owner explicitly requested direct commits to master.
613 lines
20 KiB
Go
613 lines
20 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")
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestModelDisabledKeepsLiveSTT(t *testing.T) {
|
|
p := writeConfig(t, `{"workstation":{"model_disabled":true,"stt":{"url":"http://192.168.1.105:8081/transcribe","token":"secret"}}}`)
|
|
c, err := Load(p)
|
|
if err != nil {
|
|
t.Fatalf("Load: %v", err)
|
|
}
|
|
if c.Workstation == nil || c.Workstation.Stt == nil || !c.Workstation.ModelDisabled {
|
|
t.Fatalf("model-only dark state did not preserve STT: %+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/", Token: "secret"}}
|
|
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",
|
|
Token: "secret",
|
|
}}
|
|
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 TestWorkstationLANEndpointsRequireSecrets(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name string
|
|
body string
|
|
}{
|
|
{"model", `{"workstation":{"url":"http://192.168.1.105:8080"}}`},
|
|
{"stt", `{"workstation":{"url":"http://127.0.0.1:8080","stt":{"url":"http://192.168.1.105:8081/transcribe"}}}`},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
if _, err := Load(writeConfig(t, tc.body)); err == nil {
|
|
t.Fatal("Load accepted an enabled LAN endpoint with no secret")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestWorkstationDisabledAllowsEmptySecrets(t *testing.T) {
|
|
p := writeConfig(t, `{"workstation":{"disabled":true,"url":"http://192.168.1.105:8080","stt":{"url":"http://192.168.1.105:8081/transcribe"}}}`)
|
|
c, err := Load(p)
|
|
if err != nil {
|
|
t.Fatalf("Load: %v", err)
|
|
}
|
|
if c.Workstation != nil {
|
|
t.Fatalf("disabled workstation survived normalisation: %+v", c.Workstation)
|
|
}
|
|
}
|
|
|
|
func TestWorkstationArmsMayBeDisabledIndependently(t *testing.T) {
|
|
modelOff := writeConfig(t, `{"workstation":{"model_disabled":true,"url":"http://192.168.1.105:8080","stt":{"url":"http://127.0.0.1:8081/transcribe"}}}`)
|
|
if _, err := Load(modelOff); err != nil {
|
|
t.Fatalf("Load model-disabled config: %v", err)
|
|
}
|
|
|
|
sttOff := writeConfig(t, `{"workstation":{"url":"http://127.0.0.1:8080","stt":{"disabled":true,"url":"http://192.168.1.105:8081/transcribe"}}}`)
|
|
c, err := Load(sttOff)
|
|
if err != nil {
|
|
t.Fatalf("Load STT-disabled config: %v", err)
|
|
}
|
|
if c.Workstation == nil || c.Workstation.Stt != nil {
|
|
t.Fatalf("STT-only dark state changed model arm: %+v", c.Workstation)
|
|
}
|
|
}
|
|
|
|
func TestWorkstationLoopbackMayRunWithoutSecret(t *testing.T) {
|
|
p := writeConfig(t, `{"workstation":{"url":"http://127.0.0.1:8080","stt":{"url":"http://localhost:8081/transcribe"}}}`)
|
|
if _, err := Load(p); err != nil {
|
|
t.Fatalf("Load loopback development endpoints: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestTelegramLiveBlockRequiresBothSecrets(t *testing.T) {
|
|
for _, body := range []string{
|
|
`{"telegram":{"chat_id":"42"}}`,
|
|
`{"telegram":{"bot_token":"token"}}`,
|
|
} {
|
|
if _, err := Load(writeConfig(t, body)); err == nil {
|
|
t.Fatalf("Load accepted enabled Telegram block: %s", body)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestTelegramDisabledAllowsEmptySecrets(t *testing.T) {
|
|
if _, err := Load(writeConfig(t, `{"telegram":{"disabled":true}}`)); err != nil {
|
|
t.Fatalf("Load disabled Telegram: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestNtfyLiveBlockRequiresSecret(t *testing.T) {
|
|
p := writeConfig(t, `{"ntfy":{"base_url":"https://ntfy.example","topic":"maven"}}`)
|
|
if _, err := Load(p); err == nil {
|
|
t.Fatal("Load accepted enabled ntfy block with no credential")
|
|
}
|
|
}
|
|
|
|
func TestNtfyDisabledAllowsEmptySecret(t *testing.T) {
|
|
p := writeConfig(t, `{"ntfy":{"disabled":true,"base_url":"https://ntfy.example","topic":"maven"}}`)
|
|
if _, err := Load(p); err != nil {
|
|
t.Fatalf("Load disabled ntfy: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestSmartHomeLiveBlockRequiresSecret(t *testing.T) {
|
|
p := writeConfig(t, `{"smarthome":{"enabled":true,"provider":"homeassistant","url":"http://192.168.1.50:8123"}}`)
|
|
if _, err := Load(p); err == nil {
|
|
t.Fatal("Load accepted enabled Home Assistant block with no token")
|
|
}
|
|
}
|
|
|
|
func TestSmartHomeDisabledAllowsEmptySecret(t *testing.T) {
|
|
p := writeConfig(t, `{"smarthome":{"enabled":false,"provider":"homeassistant","url":"http://192.168.1.50:8123"}}`)
|
|
if _, err := Load(p); err != nil {
|
|
t.Fatalf("Load disabled Home Assistant: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestConfiguredDBKeyEnvCannotBeEmpty(t *testing.T) {
|
|
t.Setenv("MAVEN_TEST_DB_KEY", "")
|
|
c := &Config{DBKeyEnv: "MAVEN_TEST_DB_KEY"}
|
|
if _, err := c.DBEncryptionKey(); err == nil {
|
|
t.Fatal("configured empty database key did not fail startup resolution")
|
|
}
|
|
if key, err := (&Config{}).DBEncryptionKey(); err != nil || key != nil {
|
|
t.Fatalf("explicit plaintext config = (%v, %v), want (nil, nil)", key, err)
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|