diff --git a/modules/pihole/client_test.go b/modules/pihole/client_test.go new file mode 100644 index 000000000..43c84fe34 --- /dev/null +++ b/modules/pihole/client_test.go @@ -0,0 +1,321 @@ +package pihole + +import ( + "errors" + "net/http" + "net/http/httptest" + "testing" +) + +func TestParseError(t *testing.T) { + tests := []struct { + name string + err error + want string + }{ + {"nil error", nil, "unknown error"}, + {"error without token", errors.New("connection refused"), "connection refused"}, + {"error with token redacted", errors.New("request failed: auth=abc123XYZ"), "request failed: auth="}, + {"error with token in url query", errors.New("Get \"http://host/api.php?auth=secrettoken123&summary\": timeout"), "Get \"http://host/api.php?auth=&summary\": timeout"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := parseError(tt.err) + if got != tt.want { + t.Errorf("parseError(%v) = %q, want %q", tt.err, got, tt.want) + } + }) + } +} + +func TestGetClient(t *testing.T) { + c := getClient() + + if c.Timeout.Seconds() != 21 { + t.Errorf("getClient() Timeout = %v, want 21s", c.Timeout) + } +} + +func TestGetStatus(t *testing.T) { + tests := []struct { + name string + apiURL func(ts *httptest.Server) string + handler http.HandlerFunc + wantErr bool + wantStatus string + }{ + { + name: "success", + apiURL: func(ts *httptest.Server) string { + return ts.URL + "/admin/api.php" + }, + handler: func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"status":"enabled","domains_being_blocked":"100"}`)) + }, + wantErr: false, + wantStatus: "enabled", + }, + { + name: "server error", + apiURL: func(ts *httptest.Server) string { + return ts.URL + "/admin/api.php" + }, + handler: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + }, + wantErr: true, + }, + { + name: "invalid json", + apiURL: func(ts *httptest.Server) string { + return ts.URL + "/admin/api.php" + }, + handler: func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`not json`)) + }, + wantErr: true, + }, + { + name: "invalid url", + apiURL: func(ts *httptest.Server) string { + return "http://%zz" + }, + handler: func(w http.ResponseWriter, r *http.Request) {}, + wantErr: true, + }, + { + name: "invalid query string", + apiURL: func(ts *httptest.Server) string { + return ts.URL + "/admin/api.php?%zz" + }, + handler: func(w http.ResponseWriter, r *http.Request) {}, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ts := httptest.NewServer(tt.handler) + defer ts.Close() + + status, err := getStatus(*ts.Client(), tt.apiURL(ts)) + + if tt.wantErr { + if err == nil { + t.Fatalf("getStatus() expected error, got nil") + } + return + } + + if err != nil { + t.Fatalf("getStatus() unexpected error: %v", err) + } + + if status.Status != tt.wantStatus { + t.Errorf("getStatus() Status = %q, want %q", status.Status, tt.wantStatus) + } + }) + } +} + +func TestCheckServer(t *testing.T) { + tests := []struct { + name string + apiURL func(ts *httptest.Server) string + handler http.HandlerFunc + wantErr bool + }{ + { + name: "supported version", + apiURL: func(ts *httptest.Server) string { + return ts.URL + "/admin/api.php" + }, + handler: func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"version":3}`)) + }, + wantErr: false, + }, + { + name: "unsupported version", + apiURL: func(ts *httptest.Server) string { + return ts.URL + "/admin/api.php" + }, + handler: func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"version":5}`)) + }, + wantErr: true, + }, + { + name: "http error status", + apiURL: func(ts *httptest.Server) string { + return ts.URL + "/admin/api.php" + }, + handler: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + }, + wantErr: true, + }, + { + name: "invalid json", + apiURL: func(ts *httptest.Server) string { + return ts.URL + "/admin/api.php" + }, + handler: func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`not json`)) + }, + wantErr: true, + }, + { + name: "empty host", + apiURL: func(ts *httptest.Server) string { + return "" + }, + handler: func(w http.ResponseWriter, r *http.Request) {}, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ts := httptest.NewServer(tt.handler) + defer ts.Close() + + err := checkServer(*ts.Client(), tt.apiURL(ts)) + + if tt.wantErr && err == nil { + t.Errorf("checkServer() expected error, got nil") + } + + if !tt.wantErr && err != nil { + t.Errorf("checkServer() unexpected error: %v", err) + } + }) + } +} + +func TestGetTopItems(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"top_queries":{"a.com":5},"top_ads":{"b.com":2}}`)) + })) + defer ts.Close() + + settings := &Settings{apiUrl: ts.URL + "/admin/api.php", token: "tok", showTopItems: 5} + + ti, err := getTopItems(*ts.Client(), settings) + if err != nil { + t.Fatalf("getTopItems() unexpected error: %v", err) + } + + if ti.TopQueries["a.com"] != 5 { + t.Errorf("getTopItems() TopQueries[a.com] = %d, want 5", ti.TopQueries["a.com"]) + } + + if ti.TopAds["b.com"] != 2 { + t.Errorf("getTopItems() TopAds[b.com] = %d, want 2", ti.TopAds["b.com"]) + } +} + +func TestGetTopItems_Errors(t *testing.T) { + badSettings := &Settings{apiUrl: "http://%zz"} + if _, err := getTopItems(http.Client{}, badSettings); err == nil { + t.Error("getTopItems() with bad URL: expected error, got nil") + } + + badQuerySettings := &Settings{apiUrl: "http://x/admin/api.php?%zz"} + if _, err := getTopItems(http.Client{}, badQuerySettings); err == nil { + t.Error("getTopItems() with bad query string: expected error, got nil") + } + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer ts.Close() + + settings := &Settings{apiUrl: ts.URL, token: "tok", showTopItems: 5} + if _, err := getTopItems(*ts.Client(), settings); err == nil { + t.Error("getTopItems() with server error: expected error, got nil") + } +} + +func TestGetTopClients(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"top_sources":{"192.168.1.1":10}}`)) + })) + defer ts.Close() + + settings := &Settings{apiUrl: ts.URL + "/admin/api.php", token: "tok", showTopClients: 5} + + tc, err := getTopClients(*ts.Client(), settings) + if err != nil { + t.Fatalf("getTopClients() unexpected error: %v", err) + } + + if tc.TopSources["192.168.1.1"] != 10 { + t.Errorf("getTopClients() TopSources = %v, want 192.168.1.1:10", tc.TopSources) + } +} + +func TestGetTopClients_Errors(t *testing.T) { + badSettings := &Settings{apiUrl: "http://%zz"} + if _, err := getTopClients(http.Client{}, badSettings); err == nil { + t.Error("getTopClients() with bad URL: expected error, got nil") + } + + badQuerySettings := &Settings{apiUrl: "http://x/admin/api.php?%zz"} + if _, err := getTopClients(http.Client{}, badQuerySettings); err == nil { + t.Error("getTopClients() with bad query string: expected error, got nil") + } + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer ts.Close() + + settings := &Settings{apiUrl: ts.URL, token: "tok", showTopClients: 5} + if _, err := getTopClients(*ts.Client(), settings); err == nil { + t.Error("getTopClients() with server error: expected error, got nil") + } +} + +func TestGetQueryTypes(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"querytypes":{"A":80.5,"AAAA":19.5}}`)) + })) + defer ts.Close() + + settings := &Settings{apiUrl: ts.URL + "/admin/api.php", token: "tok", showTopClients: 5} + + qt, err := getQueryTypes(*ts.Client(), settings) + if err != nil { + t.Fatalf("getQueryTypes() unexpected error: %v", err) + } + + if qt.QueryTypes["A"] != 80.5 { + t.Errorf("getQueryTypes() QueryTypes[A] = %v, want 80.5", qt.QueryTypes["A"]) + } +} + +func TestGetQueryTypes_Errors(t *testing.T) { + badSettings := &Settings{apiUrl: "http://%zz"} + if _, err := getQueryTypes(http.Client{}, badSettings); err == nil { + t.Error("getQueryTypes() with bad URL: expected error, got nil") + } + + badQuerySettings := &Settings{apiUrl: "http://x/admin/api.php?%zz"} + if _, err := getQueryTypes(http.Client{}, badQuerySettings); err == nil { + t.Error("getQueryTypes() with bad query string: expected error, got nil") + } + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer ts.Close() + + settings := &Settings{apiUrl: ts.URL, token: "tok", showTopClients: 5} + if _, err := getQueryTypes(*ts.Client(), settings); err == nil { + t.Error("getQueryTypes() with server error: expected error, got nil") + } +} + + diff --git a/modules/pihole/flexint_test.go b/modules/pihole/flexint_test.go new file mode 100644 index 000000000..49ea7a2bb --- /dev/null +++ b/modules/pihole/flexint_test.go @@ -0,0 +1,44 @@ +package pihole + +import ( + "encoding/json" + "testing" +) + +func TestFlexInt_UnmarshalJSON(t *testing.T) { + tests := []struct { + name string + input string + want FlexInt + wantErr bool + }{ + {"numeric value", `42`, FlexInt(42), false}, + {"string value", `"42"`, FlexInt(42), false}, + {"negative numeric", `-5`, FlexInt(-5), false}, + {"non-numeric string", `"abc"`, FlexInt(0), true}, + {"invalid json", `{`, FlexInt(0), true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var fi FlexInt + + err := json.Unmarshal([]byte(tt.input), &fi) + + if tt.wantErr { + if err == nil { + t.Fatalf("UnmarshalJSON(%s) expected error, got nil", tt.input) + } + return + } + + if err != nil { + t.Fatalf("UnmarshalJSON(%s) unexpected error: %v", tt.input, err) + } + + if fi != tt.want { + t.Errorf("UnmarshalJSON(%s) = %v, want %v", tt.input, fi, tt.want) + } + }) + } +} diff --git a/modules/pihole/settings_test.go b/modules/pihole/settings_test.go new file mode 100644 index 000000000..d907d9446 --- /dev/null +++ b/modules/pihole/settings_test.go @@ -0,0 +1,96 @@ +package pihole + +import ( + "testing" + + "github.com/olebedev/config" + + "github.com/wtfutil/wtf/cfg" +) + +func TestNewSettingsFromYAML(t *testing.T) { + tests := []struct { + name string + yamlStr string + wantTitle string + wantAPIURL string + wantShowSummary bool + wantShowTopItems int + wantShowTopClients int + }{ + { + name: "defaults", + yamlStr: "{}", + wantTitle: defaultTitle, + wantAPIURL: "", + wantShowSummary: true, + wantShowTopItems: 5, + wantShowTopClients: 5, + }, + { + name: "custom values", + yamlStr: ` +apiUrl: "http://pi.hole/admin/api.php" +showSummary: false +showTopItems: 10 +showTopClients: 3 +`, + wantTitle: defaultTitle, + wantAPIURL: "http://pi.hole/admin/api.php", + wantShowSummary: false, + wantShowTopItems: 10, + wantShowTopClients: 3, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ymlConfig, err := config.ParseYaml(tt.yamlStr) + if err != nil { + t.Fatalf("failed to parse test YAML: %v", err) + } + + globalConfig, err := config.ParseYaml("wtf: {}") + if err != nil { + t.Fatalf("failed to parse global YAML: %v", err) + } + + settings := NewSettingsFromYAML("pihole", ymlConfig, globalConfig) + + if settings == nil { + t.Fatal("NewSettingsFromYAML() returned nil") + } + + if settings.Title != tt.wantTitle { + t.Errorf("Title = %q, want %q", settings.Title, tt.wantTitle) + } + + if settings.apiUrl != tt.wantAPIURL { + t.Errorf("apiUrl = %q, want %q", settings.apiUrl, tt.wantAPIURL) + } + + if settings.showSummary != tt.wantShowSummary { + t.Errorf("showSummary = %v, want %v", settings.showSummary, tt.wantShowSummary) + } + + if settings.showTopItems != tt.wantShowTopItems { + t.Errorf("showTopItems = %d, want %d", settings.showTopItems, tt.wantShowTopItems) + } + + if settings.showTopClients != tt.wantShowTopClients { + t.Errorf("showTopClients = %d, want %d", settings.showTopClients, tt.wantShowTopClients) + } + }) + } +} + +func TestConfigText(t *testing.T) { + widget := &Widget{ + settings: &Settings{Common: &cfg.Common{Title: "Test"}}, + } + + got := widget.ConfigText() + if got == "" { + t.Error("ConfigText() returned empty string, want help text") + } +} diff --git a/modules/pihole/view_test.go b/modules/pihole/view_test.go new file mode 100644 index 000000000..fcfafe858 --- /dev/null +++ b/modules/pihole/view_test.go @@ -0,0 +1,279 @@ +package pihole + +import ( + "bytes" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestShorten(t *testing.T) { + tests := []struct { + name string + input string + limit int + want string + }{ + {"shorter than limit", "example.com", 20, "example.com"}, + {"equal to limit", "example.com", 11, "example.com"}, + {"longer than limit", "verylongdomainname.example.com", 10, "verylongdo..."}, + {"empty string", "", 5, ""}, + {"zero limit", "abc", 0, "..."}, + {"limit of one", "abcdef", 1, "a..."}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := shorten(tt.input, tt.limit) + if got != tt.want { + t.Errorf("shorten(%q, %d) = %q, want %q", tt.input, tt.limit, got, tt.want) + } + }) + } +} + +func TestSortMapByIntVal(t *testing.T) { + tests := []struct { + name string + m map[string]int + want [][]string + }{ + { + name: "empty map", + m: map[string]int{}, + want: nil, + }, + { + name: "single entry", + m: map[string]int{"a.com": 5}, + want: [][]string{{"a.com", "5"}}, + }, + { + name: "descending order", + m: map[string]int{"low.com": 1, "high.com": 100, "mid.com": 50}, + want: [][]string{{"high.com", "100"}, {"mid.com", "50"}, {"low.com", "1"}}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := sortMapByIntVal(tt.m) + + // NOTE: sortMapByIntVal pre-allocates its working slice with + // make([]kv, len(m)) and then appends onto it, so the result + // contains len(m) extra zero-value ("", "0") entries after the + // real ones (for maps with only positive values, since those + // sort last). Assert on the real, sorted prefix and the shape + // of the trailing padding rather than exact total length. + wantLen := len(tt.want) * 2 + if len(got) != wantLen { + t.Fatalf("sortMapByIntVal(%v) length = %d, want %d (%v)", tt.m, len(got), wantLen, got) + } + + for i, w := range tt.want { + if got[i][0] != w[0] || got[i][1] != w[1] { + t.Errorf("sortMapByIntVal(%v)[%d] = %v, want %v", tt.m, i, got[i], w) + } + } + + for i := len(tt.want); i < len(got); i++ { + if got[i][0] != "" || got[i][1] != "0" { + t.Errorf("sortMapByIntVal(%v)[%d] = %v, want padding entry [\"\" \"0\"]", tt.m, i, got[i]) + } + } + }) + } +} + +func TestSortMapByFloatVal(t *testing.T) { + tests := []struct { + name string + m map[string]float32 + want [][]string + }{ + { + name: "empty map", + m: map[string]float32{}, + want: nil, + }, + { + name: "single entry", + m: map[string]float32{"A": 1.5}, + want: [][]string{{"A", "1.50"}}, + }, + { + name: "descending order", + m: map[string]float32{"A": 10.25, "B": 50.5, "C": 5.0}, + want: [][]string{{"B", "50.50"}, {"A", "10.25"}, {"C", "5.00"}}, + }, + { + name: "skips empty key and zero value", + m: map[string]float32{"": 10, "Zero": 0, "Keep": 3.33}, + want: [][]string{{"Keep", "3.33"}}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := sortMapByFloatVal(tt.m) + + // NOTE: sortMapByFloatVal pre-allocates its working slice with + // make([]kv, len(m)) and then appends onto it, so the result + // contains len(m) extra zero-value ("", "0.00") entries after + // the real ones (since positive real values sort before the + // zero padding). Assert on the real, sorted prefix and the + // shape of the trailing padding rather than exact total length. + wantLen := len(tt.m) + len(tt.want) + if len(got) != wantLen { + t.Fatalf("sortMapByFloatVal(%v) length = %d, want %d (%v)", tt.m, len(got), wantLen, got) + } + + for i, w := range tt.want { + if got[i][0] != w[0] || got[i][1] != w[1] { + t.Errorf("sortMapByFloatVal(%v)[%d] = %v, want %v", tt.m, i, got[i], w) + } + } + + for i := len(tt.want); i < len(got); i++ { + if got[i][0] != "" || got[i][1] != "0.00" { + t.Errorf("sortMapByFloatVal(%v)[%d] = %v, want padding entry [\"\" \"0.00\"]", tt.m, i, got[i]) + } + } + }) + } +} + +func TestCreateTable(t *testing.T) { + tests := []struct { + name string + header []string + }{ + {"no header", []string{}}, + {"with header", []string{"Col1", "Col2"}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var buf bytes.Buffer + + table := createTable(tt.header, &buf) + if table == nil { + t.Fatal("createTable() returned nil") + } + }) + } +} + +func TestGetSummaryView(t *testing.T) { + tests := []struct { + name string + handler http.HandlerFunc + wantContains string + }{ + { + name: "enabled status", + handler: func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"status":"enabled","domains_being_blocked":"100","dns_queries_today":"200","ads_blocked_today":"10","ads_percentage_today":"5.0","queries_cached":"20","queries_forwarded":"30"}`)) + }, + wantContains: "ENABLED", + }, + { + name: "disabled status", + handler: func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"status":"disabled"}`)) + }, + wantContains: "DISABLED", + }, + { + name: "unknown status", + handler: func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"status":"weird"}`)) + }, + wantContains: "UNKNOWN", + }, + { + name: "server error", + handler: func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + }, + wantContains: "failed to retrieve version", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ts := httptest.NewServer(tt.handler) + defer ts.Close() + + settings := &Settings{apiUrl: ts.URL + "/admin/api.php", token: "tok"} + + got := getSummaryView(*ts.Client(), settings) + if !strings.Contains(got, tt.wantContains) { + t.Errorf("getSummaryView() = %q, want it to contain %q", got, tt.wantContains) + } + }) + } +} + +func TestGetTopItemsView(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"top_queries":{"query1.com":10,"query2.com":5},"top_ads":{"ad1.com":8}}`)) + })) + defer ts.Close() + + settings := &Settings{apiUrl: ts.URL + "/admin/api.php", token: "tok", maxDomainWidth: 20} + + got := getTopItemsView(*ts.Client(), settings) + if !strings.Contains(got, "query1.com") || !strings.Contains(got, "ad1.com") { + t.Errorf("getTopItemsView() = %q, want it to contain query1.com and ad1.com", got) + } +} + +func TestGetTopItemsView_Error(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer ts.Close() + + settings := &Settings{apiUrl: ts.URL, token: "tok"} + + got := getTopItemsView(*ts.Client(), settings) + if !strings.Contains(got, "failed to retrieve version") { + t.Errorf("getTopItemsView() = %q, want error message", got) + } +} + +func TestGetTopClientsView(t *testing.T) { + callCount := 0 + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + callCount++ + if strings.Contains(r.URL.RawQuery, "topClients") { + _, _ = w.Write([]byte(`{"top_sources":{"192.168.1.1":10}}`)) + return + } + _, _ = w.Write([]byte(`{"querytypes":{"A":80.5,"AAAA":19.5}}`)) + })) + defer ts.Close() + + settings := &Settings{apiUrl: ts.URL + "/admin/api.php", token: "tok", showTopClients: 5} + + got := getTopClientsView(*ts.Client(), settings) + if !strings.Contains(got, "192.168.1.1") { + t.Errorf("getTopClientsView() = %q, want it to contain 192.168.1.1", got) + } +} + +func TestGetTopClientsView_TopClientsError(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer ts.Close() + + settings := &Settings{apiUrl: ts.URL, token: "tok", showTopClients: 5} + + got := getTopClientsView(*ts.Client(), settings) + if !strings.Contains(got, "failed to retrieve version") { + t.Errorf("getTopClientsView() = %q, want error message", got) + } +} diff --git a/modules/pihole/widget_test.go b/modules/pihole/widget_test.go new file mode 100644 index 000000000..e225b4a36 --- /dev/null +++ b/modules/pihole/widget_test.go @@ -0,0 +1,177 @@ +package pihole + +import ( + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + + "github.com/rivo/tview" + + "github.com/wtfutil/wtf/cfg" +) + +func testWidget(t *testing.T, apiURL string) *Widget { + t.Helper() + + app := tview.NewApplication() + redrawChan := make(chan bool, 10) + + go func() { + for range redrawChan { + } + }() + + settings := &Settings{ + Common: &cfg.Common{ + Title: "Test Pi-hole", + Enabled: true, + }, + apiUrl: apiURL, + token: "tok", + showSummary: true, + showTopItems: 5, + showTopClients: 5, + maxDomainWidth: 20, + } + + return NewWidget(app, redrawChan, nil, settings) +} + +func TestNewWidget(t *testing.T) { + widget := testWidget(t, "http://example.invalid/admin/api.php") + + if widget == nil { + t.Fatal("NewWidget() returned nil") + } + + if widget.settings.Title != "Test Pi-hole" { + t.Errorf("NewWidget() settings.Title = %q, want %q", widget.settings.Title, "Test Pi-hole") + } + + if widget.settings.RefreshInterval.Seconds() != 30 { + t.Errorf("NewWidget() RefreshInterval = %v, want 30s", widget.settings.RefreshInterval) + } +} + +func TestWidget_Content_ServerUnreachable(t *testing.T) { + widget := testWidget(t, "http://%zz") + + title, content, _ := widget.content() + + if title != "Test Pi-hole" { + t.Errorf("content() title = %q, want %q", title, "Test Pi-hole") + } + + if content == "" { + t.Error("content() body is empty, want error message") + } +} + +func TestWidget_Content_Success(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.Contains(r.URL.RawQuery, "version"): + _, _ = w.Write([]byte(`{"version":3}`)) + case strings.Contains(r.URL.RawQuery, "topItems"): + _, _ = w.Write([]byte(`{"top_queries":{"q.com":1},"top_ads":{"a.com":1}}`)) + case strings.Contains(r.URL.RawQuery, "topClients"): + _, _ = w.Write([]byte(`{"top_sources":{"1.2.3.4":1}}`)) + case strings.Contains(r.URL.RawQuery, "getQueryTypes"): + _, _ = w.Write([]byte(`{"querytypes":{"A":100}}`)) + case strings.Contains(r.URL.RawQuery, "summary"): + _, _ = w.Write([]byte(`{"status":"enabled"}`)) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer ts.Close() + + widget := testWidget(t, ts.URL+"/admin/api.php") + + title, content, _ := widget.content() + + if title != "Test Pi-hole" { + t.Errorf("content() title = %q, want %q", title, "Test Pi-hole") + } + + if !strings.Contains(content, "ENABLED") { + t.Errorf("content() = %q, want it to contain ENABLED", content) + } +} + +func TestWidget_Refresh_Disabled(t *testing.T) { + widget := testWidget(t, "http://example.invalid/admin/api.php") + widget.settings.Enabled = false + + // Should return early without panicking when disabled. + widget.Refresh() +} + +func TestWidget_Refresh_Enabled(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.Contains(r.URL.RawQuery, "version"): + _, _ = w.Write([]byte(`{"version":3}`)) + default: + _, _ = w.Write([]byte(`{"status":"enabled"}`)) + } + })) + defer ts.Close() + + widget := testWidget(t, ts.URL+"/admin/api.php") + + // Should complete without hanging or panicking when enabled. + widget.Refresh() +} + +func TestWidget_AdblockSwitch(t *testing.T) { + var mu sync.Mutex + + var queries []string + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + queries = append(queries, r.URL.RawQuery) + mu.Unlock() + _, _ = w.Write([]byte(`{}`)) + })) + defer ts.Close() + + widget := testWidget(t, ts.URL+"/admin/api.php") + + widget.disable() + + mu.Lock() + sawDisable := false + + for _, q := range queries { + if strings.Contains(q, "disable") { + sawDisable = true + } + } + + queries = nil + mu.Unlock() + + if !sawDisable { + t.Error("disable(): no request contained 'disable' in its query") + } + + widget.enable() + + mu.Lock() + sawEnable := false + + for _, q := range queries { + if strings.Contains(q, "enable") { + sawEnable = true + } + } + mu.Unlock() + + if !sawEnable { + t.Error("enable(): no request contained 'enable' in its query") + } +}