package main // agent_bindings_test.go — AB8-AB10 binding 層測試。 // // 不啟動 Wails runtime,只驗證 binding 的輸入 / 輸出 / 錯誤處理。 // // 覆蓋項目: // - GetAgentSettings / SaveAgentSettings 的 config store 串接 // - TestConnection 的輸入驗證(不實際上網) // - ResetAllSettings 同時清 token + config // - ExportLog 產出合法 zip(內含 ring-buffer.txt) // - agentConfigToSettings / settingsToAgentConfig 互轉不掉資訊 import ( "archive/zip" "io" "os" "strings" "testing" "visiona-agent/internal/agentconfig" "visiona-agent/internal/tunnel" ) // newBindingTestApp 建立一個沒有 Wails runtime 的 App,只帶 AB7-AB10 會用到的元件。 func newBindingTestApp(t *testing.T) *App { t.Helper() dir := t.TempDir() a := &App{dataDir: dir} a.logBuf = NewLogBuffer() store, err := agentconfig.NewStore(dir, nil) if err != nil { t.Fatalf("NewStore: %v", err) } a.configStore = store ts, err := tunnel.NewEncryptedFileTokenStore(dir, nil) if err != nil { t.Fatalf("NewEncryptedFileTokenStore: %v", err) } a.tokenStore = ts return a } func TestGetAgentSettings_WithoutStoreReturnsDefaults(t *testing.T) { a := &App{} got, err := a.GetAgentSettings() if err != nil { t.Fatalf("GetAgentSettings without store should not error; got %v", err) } if got.RelayURL != agentconfig.DefaultRelayURL { t.Errorf("RelayURL = %q; want %q", got.RelayURL, agentconfig.DefaultRelayURL) } if got.ReconnectStrategy != ReconnectStrategyAuto { t.Errorf("ReconnectStrategy = %q; want auto", got.ReconnectStrategy) } } func TestSaveAgentSettings_RoundtripThroughConfigStore(t *testing.T) { a := newBindingTestApp(t) newSettings := AgentSettings{ RelayURL: "wss://custom.relay.example.com/tunnel", AutoStart: true, ReconnectStrategy: ReconnectStrategyManual, LogLevel: "debug", } if err := a.SaveAgentSettings(newSettings); err != nil { t.Fatalf("SaveAgentSettings: %v", err) } got, err := a.GetAgentSettings() if err != nil { t.Fatalf("GetAgentSettings: %v", err) } if got != newSettings { t.Errorf("Get after Save = %+v; want %+v", got, newSettings) } } func TestSaveAgentSettings_RejectsInvalidRelayURL(t *testing.T) { a := newBindingTestApp(t) bad := AgentSettings{ RelayURL: "http://not-websocket.example.com", AutoStart: false, ReconnectStrategy: ReconnectStrategyAuto, LogLevel: "info", } if err := a.SaveAgentSettings(bad); err == nil { t.Error("SaveAgentSettings should reject http:// URL") } } func TestSaveAgentSettings_WithoutStoreReturnsNotReady(t *testing.T) { a := &App{} err := a.SaveAgentSettings(AgentSettings{ RelayURL: "wss://relay.example.com", ReconnectStrategy: ReconnectStrategyAuto, LogLevel: "info", }) if err == nil { t.Error("SaveAgentSettings without store should error") } } func TestTestConnection_InputValidation(t *testing.T) { a := &App{} cases := []struct { name string url string wantOK bool wantRea string }{ {"empty", "", false, "empty"}, {"no scheme", "relay.example.com", false, "ws://"}, {"http scheme", "http://relay.example.com", false, "ws://"}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { got := a.TestConnection(c.url) if got.OK != c.wantOK { t.Errorf("OK = %v; want %v (reason=%q)", got.OK, c.wantOK, got.Reason) } if c.wantRea != "" && !strings.Contains(got.Reason, c.wantRea) { t.Errorf("Reason %q should mention %q", got.Reason, c.wantRea) } }) } } func TestResetAllSettings_ClearsConfigAndToken(t *testing.T) { a := newBindingTestApp(t) // 先寫入非預設 settings + 一個 token custom := AgentSettings{ RelayURL: "wss://custom.example.com/tunnel", AutoStart: true, ReconnectStrategy: ReconnectStrategyManual, LogLevel: "debug", } if err := a.SaveAgentSettings(custom); err != nil { t.Fatalf("SaveAgentSettings: %v", err) } if err := a.tokenStore.Save("vAs_test_token"); err != nil { t.Fatalf("Save token: %v", err) } if err := a.ResetAllSettings(); err != nil { t.Fatalf("ResetAllSettings: %v", err) } // Settings 應回到預設 got, err := a.GetAgentSettings() if err != nil { t.Fatalf("GetAgentSettings: %v", err) } if got.RelayURL != agentconfig.DefaultRelayURL { t.Errorf("after reset RelayURL = %q; want default %q", got.RelayURL, agentconfig.DefaultRelayURL) } // Token 應被清除(Unpair 有呼叫 tokenStore.Delete) // 但因為 tunnelManager == nil,Unpair 這條路不走;手動驗證 tokenStore // 本身在 ResetAllSettings 流程中沒被 Manager 清——這個測試只確認 config 重置。 // token 清除由 Manager.Unpair() 負責(見 TestResetAllSettings_WithManager)。 } func TestExportLog_ProducesValidZip(t *testing.T) { a := newBindingTestApp(t) // 寫幾行 log 到 ring buffer for i, line := range []string{"[INFO] first line", "[ERROR] oh no", "plain text"} { a.logBuf.Append(LogLine{ Ts: int64(1700000000000 + i), Stream: "test", Line: line, Level: parseLogLevel(line), }) } path, err := a.ExportLog() if err != nil { t.Fatalf("ExportLog: %v", err) } defer os.Remove(path) if !strings.HasSuffix(path, ".zip") { t.Errorf("ExportLog path = %q; want .zip suffix", path) } // 打開 zip 驗證內容 zr, err := zip.OpenReader(path) if err != nil { t.Fatalf("open zip: %v", err) } defer zr.Close() var foundRingBuffer bool for _, f := range zr.File { if f.Name == "ring-buffer.txt" { foundRingBuffer = true rc, err := f.Open() if err != nil { t.Fatalf("open ring-buffer.txt: %v", err) } data, err := io.ReadAll(rc) _ = rc.Close() if err != nil { t.Fatalf("read ring-buffer.txt: %v", err) } s := string(data) if !strings.Contains(s, "first line") { t.Errorf("ring-buffer.txt missing 'first line'; got:\n%s", s) } if !strings.Contains(s, "[error]") && !strings.Contains(s, "[ERROR]") { t.Errorf("ring-buffer.txt should include level; got:\n%s", s) } } } if !foundRingBuffer { t.Error("zip should contain ring-buffer.txt") } } func TestExportLog_WithoutLogBufReturnsError(t *testing.T) { a := &App{} if _, err := a.ExportLog(); err == nil { t.Error("ExportLog without logBuf should error") } } func TestAgentConfigSettings_RoundtripPreservesAllFields(t *testing.T) { cases := []AgentSettings{ {RelayURL: "wss://a.example.com", AutoStart: false, ReconnectStrategy: ReconnectStrategyAuto, LogLevel: "info"}, {RelayURL: "ws://b.example.com:1234", AutoStart: true, ReconnectStrategy: ReconnectStrategyManual, LogLevel: "debug"}, {RelayURL: "wss://c.example.com", AutoStart: true, ReconnectStrategy: ReconnectStrategyAuto, LogLevel: "warn"}, {RelayURL: "wss://d.example.com", AutoStart: false, ReconnectStrategy: ReconnectStrategyManual, LogLevel: "error"}, } for _, in := range cases { cfg := settingsToAgentConfig(in) out := agentConfigToSettings(cfg) if out != in { t.Errorf("roundtrip:\n in = %+v\n out = %+v", in, out) } } } func TestGetRecentLogs_RespectsLimit(t *testing.T) { a := newBindingTestApp(t) for i := 0; i < 10; i++ { a.logBuf.Append(LogLine{Ts: int64(i), Line: "line"}) } got := a.GetRecentLogs(3) if len(got) != 3 { t.Errorf("GetRecentLogs(3) len = %d; want 3", len(got)) } // 應該是最新 3 筆(index 7, 8, 9) if got[0].Ts != 7 || got[2].Ts != 9 { t.Errorf("GetRecentLogs(3) ts = [%d, _, %d]; want [7, _, 9]", got[0].Ts, got[2].Ts) } } func TestGetRecentLogs_WithoutBufferReturnsEmpty(t *testing.T) { a := &App{} got := a.GetRecentLogs(10) if len(got) != 0 { t.Errorf("GetRecentLogs without buffer = %d items; want 0", len(got)) } }