mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-24 14:47:23 +00:00
feat(service): add Health tab with datastore checks and quick fixes
Discussion #295 surfaced that a paired device without Sources.xml silently breaks playback — /full omits TUNEIN and selection fails with 1005. initializeDefaultSources only runs at startup over existing devices, so a device that checks in later is never seeded. Add a Health tab to the admin UI that runs registered checks against the datastore and offers one-click remediations. The first check flags missing Sources.xml per device; its quick fix writes the canonical defaults via SaveConfiguredSources. The check/fix registry is designed so adding Presets.xml, Recents.xml, or future reachability probes is a one-file diff. - New /setup/health (GET) and /setup/health/fix (POST) routes - pkg/service/health: Registry, Check, Finding, QuickFix types - Sources.xml-present check + create_default_sources fix - "7. Health" tab in pkg/service/handlers/web/ Inspired by issue #327's MAINTENANCE tab proposal; curl/URL helper content from that issue can slot into the same tab in a follow-up. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
882d0633fb
commit
f791145976
@@ -1154,6 +1154,9 @@ func setupRouter(server *handlers.Server, stockholmHandler *stockholm.Handler) *
|
||||
|
||||
r.Get("/devices/{deviceId}/events", server.HandleGetDeviceEvents)
|
||||
|
||||
r.Get("/health", server.HandleHealthChecks)
|
||||
r.Post("/health/fix", server.HandleHealthFix)
|
||||
|
||||
// Serve Stockholm setup wizard pages for paths not matched by the management API.
|
||||
// The Stockholm frontend has a setup/ directory that must be accessible at /setup/*.
|
||||
if stockholmHandler != nil {
|
||||
|
||||
@@ -58,6 +58,7 @@ GET /setup/devices/{deviceId}/events handlers.(
|
||||
GET /setup/discovery-status handlers.(*Server).HandleGetDiscoveryStatus-fm
|
||||
GET /setup/dns-discoveries handlers.(*Server).HandleGetDNSDiscoveries-fm
|
||||
GET /setup/dns-discoveries/download handlers.(*Server).HandleDownloadDNSDiscoveries-fm
|
||||
GET /setup/health handlers.(*Server).HandleHealthChecks-fm
|
||||
GET /setup/info/{deviceId} handlers.(*Server).HandleGetDeviceInfo-fm
|
||||
GET /setup/interaction-content handlers.(*Server).HandleGetInteractionContent-fm
|
||||
GET /setup/interaction-stats handlers.(*Server).HandleGetInteractionStats-fm
|
||||
@@ -127,6 +128,7 @@ POST /setup/backup/{deviceId} handlers.(
|
||||
POST /setup/devices handlers.(*Server).HandleAddManualDevice-fm
|
||||
POST /setup/discover handlers.(*Server).HandleTriggerDiscovery-fm
|
||||
POST /setup/ensure-remote-services/{deviceId} handlers.(*Server).HandleEnsureRemoteServices-fm
|
||||
POST /setup/health/fix handlers.(*Server).HandleHealthFix-fm
|
||||
POST /setup/logging-settings handlers.(*Server).HandleUpdateLoggingSettings-fm
|
||||
POST /setup/migrate/{deviceId} handlers.(*Server).HandleMigrateDevice-fm
|
||||
POST /setup/pair-account/{deviceId} handlers.(*Server).HandlePairAccount-fm
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/health"
|
||||
)
|
||||
|
||||
// healthChecksResponse is the wire shape for GET /setup/health.
|
||||
type healthChecksResponse struct {
|
||||
GeneratedAt string `json:"generatedAt"`
|
||||
Checks []health.CheckResult `json:"checks"`
|
||||
}
|
||||
|
||||
// healthFixRequest is the wire shape for POST /setup/health/fix.
|
||||
// Target locates the entity the fix should act on; an empty
|
||||
// Account/Device pair is allowed for service-wide fixes.
|
||||
type healthFixRequest struct {
|
||||
CheckID string `json:"checkId"`
|
||||
FixID string `json:"fixId"`
|
||||
Target health.Target `json:"target"`
|
||||
}
|
||||
|
||||
type healthFixResponse struct {
|
||||
OK bool `json:"ok"`
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
// HandleHealthChecks runs every registered health check and
|
||||
// returns the current findings. Safe to poll: checks are
|
||||
// expected to be cheap (filesystem stats, in-memory lookups).
|
||||
func (s *Server) HandleHealthChecks(w http.ResponseWriter, _ *http.Request) {
|
||||
if s.healthRegistry == nil {
|
||||
writeJSONError(w, http.StatusInternalServerError, "health registry not initialized")
|
||||
return
|
||||
}
|
||||
|
||||
resp := healthChecksResponse{
|
||||
GeneratedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
Checks: s.healthRegistry.RunAll(),
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if err := json.NewEncoder(w).Encode(resp); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// HandleHealthFix dispatches a quick-fix identified by
|
||||
// (checkId, fixId) against the supplied target. Returns 404 when
|
||||
// the fix isn't registered (typically a stale UI), 400 on a
|
||||
// malformed body, and 500 when the fix itself fails. The success
|
||||
// message comes from the FixFunc and is forwarded to the UI.
|
||||
func (s *Server) HandleHealthFix(w http.ResponseWriter, r *http.Request) {
|
||||
if s.healthRegistry == nil {
|
||||
writeJSONError(w, http.StatusInternalServerError, "health registry not initialized")
|
||||
return
|
||||
}
|
||||
|
||||
var req healthFixRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeJSONError(w, http.StatusBadRequest, "Invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
if req.CheckID == "" || req.FixID == "" {
|
||||
writeJSONError(w, http.StatusBadRequest, "checkId and fixId are required")
|
||||
return
|
||||
}
|
||||
|
||||
msg, err := s.healthRegistry.RunFix(req.CheckID, req.FixID, req.Target)
|
||||
if err != nil {
|
||||
if errors.Is(err, health.ErrFixNotFound) {
|
||||
writeJSONError(w, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
writeJSONError(w, http.StatusInternalServerError, err.Error())
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if err := json.NewEncoder(w).Encode(healthFixResponse{OK: true, Message: msg}); err != nil {
|
||||
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/health"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
func newHealthTestServer(t *testing.T) (*httptest.Server, *datastore.DataStore, string, string) {
|
||||
t.Helper()
|
||||
|
||||
tempDir, err := os.MkdirTemp("", "handlers-health-test-*")
|
||||
if err != nil {
|
||||
t.Fatalf("temp dir: %v", err)
|
||||
}
|
||||
|
||||
t.Cleanup(func() { os.RemoveAll(tempDir) })
|
||||
|
||||
ds := datastore.NewDataStore(tempDir)
|
||||
_ = ds.Initialize()
|
||||
|
||||
account, device := "1000001", "DEVICEID01"
|
||||
if err := ds.SaveDeviceInfo(account, device, &models.ServiceDeviceInfo{
|
||||
DeviceID: device,
|
||||
AccountID: account,
|
||||
Name: "Speaker",
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveDeviceInfo: %v", err)
|
||||
}
|
||||
|
||||
_, server := setupRouter("http://localhost:8001", ds)
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Get("/setup/health", server.HandleHealthChecks)
|
||||
r.Post("/setup/health/fix", server.HandleHealthFix)
|
||||
|
||||
ts := httptest.NewServer(r)
|
||||
t.Cleanup(ts.Close)
|
||||
|
||||
return ts, ds, account, device
|
||||
}
|
||||
|
||||
func TestHandleHealthChecks_ReportsMissingSourcesXML(t *testing.T) {
|
||||
ts, _, account, device := newHealthTestServer(t)
|
||||
|
||||
res, err := http.Get(ts.URL + "/setup/health")
|
||||
if err != nil {
|
||||
t.Fatalf("GET: %v", err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", res.StatusCode)
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
GeneratedAt string `json:"generatedAt"`
|
||||
Checks []health.CheckResult `json:"checks"`
|
||||
}
|
||||
if err := json.NewDecoder(res.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
|
||||
if resp.GeneratedAt == "" {
|
||||
t.Errorf("expected generatedAt to be populated")
|
||||
}
|
||||
|
||||
if len(resp.Checks) == 0 {
|
||||
t.Fatalf("expected at least one check")
|
||||
}
|
||||
|
||||
var found *health.CheckResult
|
||||
for i := range resp.Checks {
|
||||
if resp.Checks[i].ID == health.CheckIDSourcesXMLPresent {
|
||||
found = &resp.Checks[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if found == nil {
|
||||
t.Fatalf("expected %q check in response", health.CheckIDSourcesXMLPresent)
|
||||
}
|
||||
|
||||
if found.Severity != health.SeverityWarning {
|
||||
t.Errorf("expected warning, got %q", found.Severity)
|
||||
}
|
||||
|
||||
if len(found.Findings) != 1 {
|
||||
t.Fatalf("expected 1 finding, got %d", len(found.Findings))
|
||||
}
|
||||
|
||||
finding := found.Findings[0]
|
||||
if finding.Target.Account != account || finding.Target.Device != device {
|
||||
t.Errorf("finding target = %+v, want account=%s device=%s", finding.Target, account, device)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleHealthFix_MaterialisesAndClearsFinding(t *testing.T) {
|
||||
ts, ds, account, device := newHealthTestServer(t)
|
||||
|
||||
type fixReq struct {
|
||||
CheckID string `json:"checkId"`
|
||||
FixID string `json:"fixId"`
|
||||
Target health.Target `json:"target"`
|
||||
}
|
||||
|
||||
body, err := json.Marshal(fixReq{
|
||||
CheckID: health.CheckIDSourcesXMLPresent,
|
||||
FixID: health.FixIDCreateDefaultSources,
|
||||
Target: health.Target{Account: account, Device: device},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
|
||||
res, err := http.Post(ts.URL+"/setup/health/fix", "application/json", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
t.Fatalf("POST: %v", err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", res.StatusCode)
|
||||
}
|
||||
|
||||
var fixResp struct {
|
||||
OK bool `json:"ok"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
if err := json.NewDecoder(res.Body).Decode(&fixResp); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
|
||||
if !fixResp.OK {
|
||||
t.Errorf("expected ok=true, got %+v", fixResp)
|
||||
}
|
||||
|
||||
if !ds.HasConfiguredSources(account, device) {
|
||||
t.Fatalf("Sources.xml should exist after fix")
|
||||
}
|
||||
|
||||
// Subsequent GET should now report SeverityOK for the check.
|
||||
res2, err := http.Get(ts.URL + "/setup/health")
|
||||
if err != nil {
|
||||
t.Fatalf("re-GET: %v", err)
|
||||
}
|
||||
defer res2.Body.Close()
|
||||
|
||||
var resp struct {
|
||||
Checks []health.CheckResult `json:"checks"`
|
||||
}
|
||||
if err := json.NewDecoder(res2.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
|
||||
for i := range resp.Checks {
|
||||
if resp.Checks[i].ID != health.CheckIDSourcesXMLPresent {
|
||||
continue
|
||||
}
|
||||
if resp.Checks[i].Severity != health.SeverityOK {
|
||||
t.Errorf("expected OK after fix, got %q", resp.Checks[i].Severity)
|
||||
}
|
||||
if len(resp.Checks[i].Findings) != 0 {
|
||||
t.Errorf("expected zero findings after fix, got %d", len(resp.Checks[i].Findings))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleHealthFix_UnknownFixReturns404(t *testing.T) {
|
||||
ts, _, _, _ := newHealthTestServer(t)
|
||||
|
||||
body, err := json.Marshal(struct {
|
||||
CheckID string `json:"checkId"`
|
||||
FixID string `json:"fixId"`
|
||||
}{CheckID: "no_such_check", FixID: "no_such_fix"})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
|
||||
res, err := http.Post(ts.URL+"/setup/health/fix", "application/json", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
t.Fatalf("POST: %v", err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusNotFound {
|
||||
t.Errorf("expected 404, got %d", res.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleHealthFix_BadRequest(t *testing.T) {
|
||||
ts, _, _, _ := newHealthTestServer(t)
|
||||
|
||||
res, err := http.Post(ts.URL+"/setup/health/fix", "application/json", bytes.NewReader([]byte(`{"checkId":""}`)))
|
||||
if err != nil {
|
||||
t.Fatalf("POST: %v", err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d", res.StatusCode)
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/amazon"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/constants"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/health"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/marge"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/proxy"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/setup"
|
||||
@@ -62,6 +63,7 @@ type Server struct {
|
||||
amazonRedirectURI string
|
||||
amazonService *amazon.Service
|
||||
peerObserver *peerObserver
|
||||
healthRegistry *health.Registry
|
||||
}
|
||||
|
||||
// RequestSnapshot represents an immutable snapshot of an HTTP request.
|
||||
@@ -97,8 +99,11 @@ func NewServer(ds *datastore.DataStore, sm *setup.Manager, serverURL string, red
|
||||
discoveryInterval: 5 * time.Minute,
|
||||
discoveryEnabled: true,
|
||||
peerObserver: newPeerObserver(),
|
||||
healthRegistry: health.NewRegistry(),
|
||||
}
|
||||
|
||||
health.RegisterSourcesXMLPresent(s.healthRegistry, ds)
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
|
||||
@@ -36,6 +36,9 @@
|
||||
<button class="tab-btn" onclick="openTab(event, 'tab-account')">
|
||||
6. Local Account
|
||||
</button>
|
||||
<button class="tab-btn" onclick="openTab(event, 'tab-health')">
|
||||
7. Health
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Tab 0: Overview -->
|
||||
@@ -1490,6 +1493,21 @@
|
||||
<div id="account-devices-list">Select an account to view devices.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab 7: Health -->
|
||||
<div id="tab-health" class="tab-content">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center;">
|
||||
<h2>Service Health Checks</h2>
|
||||
<button onclick="fetchHealth()">Refresh</button>
|
||||
</div>
|
||||
<p style="font-size: 0.9em; color: #555;">
|
||||
Runs a set of checks against the local datastore and flags
|
||||
findings that may need attention. Quick fixes are offered
|
||||
for issues the service knows how to remediate.
|
||||
</p>
|
||||
<div id="health-generated-at" style="font-size: 0.8em; color: #888; margin-bottom: 10px;"></div>
|
||||
<div id="health-findings">Loading…</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/web/js/script.js"></script>
|
||||
|
||||
@@ -507,6 +507,10 @@ function openTab(evt, tabId) {
|
||||
fetchAccountList();
|
||||
}
|
||||
|
||||
if (tabId === "tab-health") {
|
||||
fetchHealth();
|
||||
}
|
||||
|
||||
if (evt) {
|
||||
evt.currentTarget.className += " active";
|
||||
let hash = tabId;
|
||||
@@ -3976,3 +3980,183 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
fetchSettings();
|
||||
triggerDiscovery();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Health tab
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function fetchHealth() {
|
||||
const findingsEl = document.getElementById("health-findings");
|
||||
const generatedAtEl = document.getElementById("health-generated-at");
|
||||
if (!findingsEl) return;
|
||||
|
||||
findingsEl.textContent = "Loading…";
|
||||
if (generatedAtEl) generatedAtEl.textContent = "";
|
||||
|
||||
try {
|
||||
const resp = await fetch("/setup/health");
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
const data = await resp.json();
|
||||
renderHealthChecks(data, findingsEl, generatedAtEl);
|
||||
} catch (e) {
|
||||
findingsEl.textContent = `Failed to load health checks: ${e.message || e}`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderHealthChecks(data, findingsEl, generatedAtEl) {
|
||||
if (generatedAtEl && data.generatedAt) {
|
||||
generatedAtEl.textContent = `Last run: ${data.generatedAt}`;
|
||||
}
|
||||
|
||||
const checks = data.checks || [];
|
||||
if (checks.length === 0) {
|
||||
findingsEl.textContent = "No checks are registered.";
|
||||
return;
|
||||
}
|
||||
|
||||
findingsEl.innerHTML = "";
|
||||
for (const check of checks) {
|
||||
findingsEl.appendChild(renderHealthCheck(check));
|
||||
}
|
||||
}
|
||||
|
||||
function renderHealthCheck(check) {
|
||||
const box = document.createElement("div");
|
||||
box.className = "summary-box";
|
||||
|
||||
const header = document.createElement("h3");
|
||||
header.style.margin = "0 0 8px 0";
|
||||
header.appendChild(severityBadge(check.severity));
|
||||
header.appendChild(document.createTextNode(" " + check.title));
|
||||
box.appendChild(header);
|
||||
|
||||
const idLine = document.createElement("div");
|
||||
idLine.style.fontSize = "0.75em";
|
||||
idLine.style.color = "#888";
|
||||
idLine.style.marginBottom = "8px";
|
||||
idLine.textContent = `id: ${check.id}`;
|
||||
box.appendChild(idLine);
|
||||
|
||||
const findings = check.findings || [];
|
||||
if (findings.length === 0) {
|
||||
const ok = document.createElement("div");
|
||||
ok.style.color = "#2e7d32";
|
||||
ok.textContent = "✓ No issues detected.";
|
||||
box.appendChild(ok);
|
||||
return box;
|
||||
}
|
||||
|
||||
for (const f of findings) {
|
||||
box.appendChild(renderFinding(check.id, f));
|
||||
}
|
||||
return box;
|
||||
}
|
||||
|
||||
function renderFinding(checkId, finding) {
|
||||
const row = document.createElement("div");
|
||||
row.style.borderTop = "1px solid #e0e0e0";
|
||||
row.style.padding = "10px 0";
|
||||
|
||||
const title = document.createElement("div");
|
||||
title.appendChild(severityBadge(finding.severity));
|
||||
title.appendChild(document.createTextNode(" " + (finding.message || "")));
|
||||
row.appendChild(title);
|
||||
|
||||
const target = finding.target || {};
|
||||
if (target.account || target.device) {
|
||||
const t = document.createElement("div");
|
||||
t.style.fontSize = "0.8em";
|
||||
t.style.color = "#666";
|
||||
t.style.marginTop = "4px";
|
||||
const parts = [];
|
||||
if (target.account) parts.push(`account ${target.account}`);
|
||||
if (target.device) parts.push(`device ${target.device}`);
|
||||
t.textContent = parts.join(" · ");
|
||||
row.appendChild(t);
|
||||
}
|
||||
|
||||
if (finding.details) {
|
||||
const d = document.createElement("div");
|
||||
d.style.fontSize = "0.85em";
|
||||
d.style.color = "#444";
|
||||
d.style.marginTop = "6px";
|
||||
d.textContent = finding.details;
|
||||
row.appendChild(d);
|
||||
}
|
||||
|
||||
const fixes = finding.quickFixes || [];
|
||||
if (fixes.length > 0) {
|
||||
const actions = document.createElement("div");
|
||||
actions.style.marginTop = "8px";
|
||||
actions.style.display = "flex";
|
||||
actions.style.gap = "8px";
|
||||
actions.style.flexWrap = "wrap";
|
||||
for (const fix of fixes) {
|
||||
const btn = document.createElement("button");
|
||||
btn.textContent = fix.label || fix.id;
|
||||
btn.onclick = () => runQuickFix(checkId, fix.id, target, fix.confirm, btn);
|
||||
actions.appendChild(btn);
|
||||
}
|
||||
const status = document.createElement("span");
|
||||
status.className = "health-fix-status";
|
||||
status.style.fontSize = "0.85em";
|
||||
status.style.alignSelf = "center";
|
||||
actions.appendChild(status);
|
||||
row.appendChild(actions);
|
||||
}
|
||||
|
||||
return row;
|
||||
}
|
||||
|
||||
function severityBadge(severity) {
|
||||
const span = document.createElement("span");
|
||||
span.style.fontSize = "0.75em";
|
||||
span.style.padding = "2px 6px";
|
||||
span.style.borderRadius = "10px";
|
||||
span.style.fontWeight = "bold";
|
||||
const palette = {
|
||||
ok: { bg: "#e8f5e9", fg: "#2e7d32", label: "OK" },
|
||||
info: { bg: "#e3f2fd", fg: "#1565c0", label: "INFO" },
|
||||
warning: { bg: "#fff8e1", fg: "#a06800", label: "WARN" },
|
||||
error: { bg: "#ffebee", fg: "#c62828", label: "ERROR" },
|
||||
};
|
||||
const p = palette[severity] || palette.info;
|
||||
span.style.background = p.bg;
|
||||
span.style.color = p.fg;
|
||||
span.textContent = p.label;
|
||||
return span;
|
||||
}
|
||||
|
||||
async function runQuickFix(checkId, fixId, target, confirmMsg, button) {
|
||||
if (confirmMsg && !window.confirm(confirmMsg)) return;
|
||||
|
||||
const status = button.parentElement.querySelector(".health-fix-status");
|
||||
button.disabled = true;
|
||||
if (status) {
|
||||
status.textContent = "Applying…";
|
||||
status.style.color = "#555";
|
||||
}
|
||||
|
||||
try {
|
||||
const resp = await fetch("/setup/health/fix", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ checkId, fixId, target }),
|
||||
});
|
||||
const data = await resp.json().catch(() => ({}));
|
||||
if (!resp.ok) throw new Error(data.error || data.message || `HTTP ${resp.status}`);
|
||||
|
||||
if (status) {
|
||||
status.textContent = data.message || "Done.";
|
||||
status.style.color = "#2e7d32";
|
||||
}
|
||||
// Refresh to drop the resolved finding.
|
||||
setTimeout(fetchHealth, 400);
|
||||
} catch (e) {
|
||||
if (status) {
|
||||
status.textContent = `Failed: ${e.message || e}`;
|
||||
status.style.color = "#c62828";
|
||||
}
|
||||
button.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
package health
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
)
|
||||
|
||||
// CheckSourcesXMLPresent is the built-in check for missing
|
||||
// Sources.xml on paired devices. Background:
|
||||
// initializeDefaultSources in cmd/soundtouch-service/main.go only
|
||||
// runs at startup over devices that already exist on disk, so a
|
||||
// device that first checks in *after* boot never gets its default
|
||||
// Sources.xml materialised. The speaker then absorbs whatever the
|
||||
// service serves on /streaming/account/{id}/full — usually without
|
||||
// TUNEIN — and playback fails with 1005 long after migration
|
||||
// looked successful. See discussion #295 for the trace.
|
||||
const (
|
||||
CheckIDSourcesXMLPresent = "sources_xml_present"
|
||||
FixIDCreateDefaultSources = "create_default_sources"
|
||||
)
|
||||
|
||||
// RegisterSourcesXMLPresent registers the sources_xml_present
|
||||
// check and its create_default_sources quick fix against r,
|
||||
// binding both to ds.
|
||||
func RegisterSourcesXMLPresent(r *Registry, ds *datastore.DataStore) {
|
||||
r.Register(Check{
|
||||
ID: CheckIDSourcesXMLPresent,
|
||||
Title: "Default sources are materialised on disk",
|
||||
Run: func() []Finding {
|
||||
return runSourcesXMLPresent(ds)
|
||||
},
|
||||
})
|
||||
|
||||
r.RegisterFix(CheckIDSourcesXMLPresent, FixIDCreateDefaultSources, func(target Target) (string, error) {
|
||||
return fixCreateDefaultSources(ds, target)
|
||||
})
|
||||
}
|
||||
|
||||
func runSourcesXMLPresent(ds *datastore.DataStore) []Finding {
|
||||
if ds == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
devices, err := ds.ListAllDevices()
|
||||
if err != nil {
|
||||
return []Finding{{
|
||||
Severity: SeverityError,
|
||||
Message: "Could not enumerate devices: " + err.Error(),
|
||||
}}
|
||||
}
|
||||
|
||||
var findings []Finding
|
||||
|
||||
for i := range devices {
|
||||
dev := &devices[i]
|
||||
if dev.AccountID == "" || dev.DeviceID == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if ds.HasConfiguredSources(dev.AccountID, dev.DeviceID) {
|
||||
continue
|
||||
}
|
||||
|
||||
findings = append(findings, Finding{
|
||||
Severity: SeverityWarning,
|
||||
Target: Target{Account: dev.AccountID, Device: dev.DeviceID},
|
||||
Message: "Sources.xml is missing for this device.",
|
||||
Details: "The /streaming/account/{id}/full response will not advertise the default sources (TUNEIN, RADIO_BROWSER, AUX). Playback may fail with error 1005 until a Sources.xml is materialised.",
|
||||
QuickFixes: []QuickFix{
|
||||
{
|
||||
ID: FixIDCreateDefaultSources,
|
||||
Label: "Create default Sources.xml",
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return findings
|
||||
}
|
||||
|
||||
func fixCreateDefaultSources(ds *datastore.DataStore, target Target) (string, error) {
|
||||
if ds == nil {
|
||||
return "", fmt.Errorf("datastore unavailable")
|
||||
}
|
||||
|
||||
if target.Account == "" || target.Device == "" {
|
||||
return "", fmt.Errorf("account and device are required")
|
||||
}
|
||||
|
||||
defaults := ds.GetDefaultSources()
|
||||
if err := ds.SaveConfiguredSources(target.Account, target.Device, defaults); err != nil {
|
||||
return "", fmt.Errorf("save Sources.xml: %w", err)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("Wrote default Sources.xml for %s", target.Device), nil
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package health
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/service/datastore"
|
||||
)
|
||||
|
||||
func newTestDatastoreWithDevice(t *testing.T, account, device string) *datastore.DataStore {
|
||||
t.Helper()
|
||||
|
||||
tempDir, err := os.MkdirTemp("", "health-test-*")
|
||||
if err != nil {
|
||||
t.Fatalf("temp dir: %v", err)
|
||||
}
|
||||
|
||||
t.Cleanup(func() { os.RemoveAll(tempDir) })
|
||||
|
||||
ds := datastore.NewDataStore(tempDir)
|
||||
|
||||
if err := ds.SaveDeviceInfo(account, device, &models.ServiceDeviceInfo{
|
||||
DeviceID: device,
|
||||
AccountID: account,
|
||||
Name: "TestSpeaker",
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveDeviceInfo: %v", err)
|
||||
}
|
||||
|
||||
return ds
|
||||
}
|
||||
|
||||
func TestSourcesXMLPresent_FlagsMissingFile(t *testing.T) {
|
||||
account, device := "1000001", "DEVICEID01"
|
||||
ds := newTestDatastoreWithDevice(t, account, device)
|
||||
|
||||
if ds.HasConfiguredSources(account, device) {
|
||||
t.Fatalf("precondition: device should not have Sources.xml yet")
|
||||
}
|
||||
|
||||
r := NewRegistry()
|
||||
RegisterSourcesXMLPresent(r, ds)
|
||||
|
||||
results := r.RunAll()
|
||||
if len(results) != 1 {
|
||||
t.Fatalf("expected 1 check result, got %d", len(results))
|
||||
}
|
||||
|
||||
check := results[0]
|
||||
if check.ID != CheckIDSourcesXMLPresent {
|
||||
t.Errorf("unexpected check id %q", check.ID)
|
||||
}
|
||||
|
||||
if check.Severity != SeverityWarning {
|
||||
t.Errorf("expected warning severity, got %q", check.Severity)
|
||||
}
|
||||
|
||||
if len(check.Findings) != 1 {
|
||||
t.Fatalf("expected 1 finding, got %d", len(check.Findings))
|
||||
}
|
||||
|
||||
finding := check.Findings[0]
|
||||
if finding.Target.Account != account || finding.Target.Device != device {
|
||||
t.Errorf("finding target %+v doesn't match device", finding.Target)
|
||||
}
|
||||
|
||||
if len(finding.QuickFixes) != 1 || finding.QuickFixes[0].ID != FixIDCreateDefaultSources {
|
||||
t.Errorf("expected create_default_sources quick fix, got %+v", finding.QuickFixes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSourcesXMLPresent_QuickFix_MaterialisesDefaults(t *testing.T) {
|
||||
account, device := "1000001", "DEVICEID01"
|
||||
ds := newTestDatastoreWithDevice(t, account, device)
|
||||
|
||||
r := NewRegistry()
|
||||
RegisterSourcesXMLPresent(r, ds)
|
||||
|
||||
msg, err := r.RunFix(CheckIDSourcesXMLPresent, FixIDCreateDefaultSources, Target{
|
||||
Account: account,
|
||||
Device: device,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("RunFix: %v", err)
|
||||
}
|
||||
|
||||
if msg == "" {
|
||||
t.Errorf("expected non-empty success message")
|
||||
}
|
||||
|
||||
if !ds.HasConfiguredSources(account, device) {
|
||||
t.Fatalf("Sources.xml was not materialised by the fix")
|
||||
}
|
||||
|
||||
// The defaults should include TUNEIN — that's the load-bearing
|
||||
// reason for this check.
|
||||
sources, err := ds.GetConfiguredSources(account, device)
|
||||
if err != nil {
|
||||
t.Fatalf("GetConfiguredSources: %v", err)
|
||||
}
|
||||
|
||||
var sawTuneIn bool
|
||||
for i := range sources {
|
||||
if sources[i].SourceKeyType == "TUNEIN" {
|
||||
sawTuneIn = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !sawTuneIn {
|
||||
t.Errorf("expected TUNEIN among default sources, got %d entries without it", len(sources))
|
||||
}
|
||||
|
||||
// Re-running the check should now report a clean state.
|
||||
results := r.RunAll()
|
||||
if results[0].Severity != SeverityOK {
|
||||
t.Errorf("expected OK after fix, got %q", results[0].Severity)
|
||||
}
|
||||
|
||||
if len(results[0].Findings) != 0 {
|
||||
t.Errorf("expected no findings after fix, got %d", len(results[0].Findings))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSourcesXMLPresent_NilDatastore(t *testing.T) {
|
||||
r := NewRegistry()
|
||||
RegisterSourcesXMLPresent(r, nil)
|
||||
|
||||
results := r.RunAll()
|
||||
if len(results) != 1 {
|
||||
t.Fatalf("expected 1 result, got %d", len(results))
|
||||
}
|
||||
|
||||
if results[0].Severity != SeverityOK {
|
||||
t.Errorf("nil datastore should produce no findings, got %q", results[0].Severity)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSourcesXMLPresent_FixRejectsEmptyTarget(t *testing.T) {
|
||||
ds := newTestDatastoreWithDevice(t, "1000001", "DEVICEID01")
|
||||
|
||||
r := NewRegistry()
|
||||
RegisterSourcesXMLPresent(r, ds)
|
||||
|
||||
if _, err := r.RunFix(CheckIDSourcesXMLPresent, FixIDCreateDefaultSources, Target{}); err == nil {
|
||||
t.Errorf("expected error for empty target, got nil")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
// Package health provides an extensible registry of operator-facing
|
||||
// health checks for the AfterTouch service. Checks inspect datastore
|
||||
// state (and, in future, speaker reachability or config) and emit
|
||||
// Findings that the admin UI renders under the Health tab. Each
|
||||
// Finding may carry one or more QuickFix descriptors; the
|
||||
// remediation itself is dispatched through a separate fix registry
|
||||
// keyed by (checkID, fixID), so the HTTP layer can never reference
|
||||
// a fix that isn't actually registered.
|
||||
package health
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Severity classifies a Finding's urgency. The UI sorts errors
|
||||
// first, then warnings, then info. An entire check with zero
|
||||
// findings is reported back at severity SeverityOK so the admin UI
|
||||
// can show a positive "✓ check passed" line instead of hiding the
|
||||
// check altogether.
|
||||
type Severity string
|
||||
|
||||
// Recognised Severity values. The UI renders findings sorted with
|
||||
// errors first; the registry rolls up a CheckResult's severity to
|
||||
// the highest among its Findings (SeverityOK when there are none).
|
||||
const (
|
||||
SeverityOK Severity = "ok"
|
||||
SeverityInfo Severity = "info"
|
||||
SeverityWarning Severity = "warning"
|
||||
SeverityError Severity = "error"
|
||||
)
|
||||
|
||||
// Target identifies what a Finding is about. Both fields are
|
||||
// optional: a service-wide finding leaves both empty, a
|
||||
// per-account finding fills Account only, and the common
|
||||
// per-device case fills both. The UI displays the populated fields
|
||||
// as a small label next to the finding.
|
||||
type Target struct {
|
||||
Account string `json:"account,omitempty"`
|
||||
Device string `json:"device,omitempty"`
|
||||
}
|
||||
|
||||
// QuickFix is a remediation a user can trigger from the UI with a
|
||||
// single click. The ID is the registry key used to look up the
|
||||
// FixFunc at POST time. Label is what the button displays. Confirm
|
||||
// is optional UI guidance ("This will overwrite the existing
|
||||
// file"); empty string means no confirmation needed.
|
||||
type QuickFix struct {
|
||||
ID string `json:"id"`
|
||||
Label string `json:"label"`
|
||||
Confirm string `json:"confirm,omitempty"`
|
||||
}
|
||||
|
||||
// Finding is the unit of output from a check. Severity should be
|
||||
// SeverityWarning or SeverityError for findings that need
|
||||
// attention; SeverityInfo is for things the operator might want to
|
||||
// notice but doesn't need to act on. Target locates the finding
|
||||
// (per-device / per-account / service-wide). QuickFixes is
|
||||
// optional.
|
||||
type Finding struct {
|
||||
Severity Severity `json:"severity"`
|
||||
Target Target `json:"target"`
|
||||
Message string `json:"message"`
|
||||
Details string `json:"details,omitempty"`
|
||||
QuickFixes []QuickFix `json:"quickFixes,omitempty"`
|
||||
}
|
||||
|
||||
// RunFunc executes a check and returns its Findings. Returning a
|
||||
// nil or empty slice means the check passed; the registry will
|
||||
// then report severity SeverityOK for the check as a whole.
|
||||
type RunFunc func() []Finding
|
||||
|
||||
// Check describes a registered check. ID is the stable identifier
|
||||
// used in API responses and in the FixFunc registry. Title is the
|
||||
// human-readable label shown in the UI.
|
||||
type Check struct {
|
||||
ID string
|
||||
Title string
|
||||
Run RunFunc
|
||||
}
|
||||
|
||||
// FixFunc executes a quick-fix on the given target and returns an
|
||||
// optional user-facing message describing what was done. An
|
||||
// error result is propagated to the UI as a fix failure.
|
||||
type FixFunc func(target Target) (string, error)
|
||||
|
||||
// CheckResult is the per-check entry in the GET /setup/health
|
||||
// response. Severity rolls up from the contained Findings: error
|
||||
// > warning > info; with no findings the severity is SeverityOK.
|
||||
type CheckResult struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Severity Severity `json:"severity"`
|
||||
Findings []Finding `json:"findings"`
|
||||
}
|
||||
|
||||
// ErrFixNotFound is returned by RunFix when no FixFunc is
|
||||
// registered under the (checkID, fixID) pair.
|
||||
var ErrFixNotFound = errors.New("quick fix not registered")
|
||||
|
||||
// Registry owns the set of checks and fixes for one Server
|
||||
// instance. The default zero value is not usable; construct via
|
||||
// NewRegistry.
|
||||
type Registry struct {
|
||||
mu sync.RWMutex
|
||||
checks []Check
|
||||
fixes map[string]FixFunc // key: "<checkID>/<fixID>"
|
||||
}
|
||||
|
||||
// NewRegistry returns an empty Registry.
|
||||
func NewRegistry() *Registry {
|
||||
return &Registry{fixes: map[string]FixFunc{}}
|
||||
}
|
||||
|
||||
// Register adds a check to the registry. Duplicate IDs replace
|
||||
// the prior entry; this is intentional so tests can override a
|
||||
// built-in check with a stub.
|
||||
func (r *Registry) Register(c Check) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
for i := range r.checks {
|
||||
if r.checks[i].ID == c.ID {
|
||||
r.checks[i] = c
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
r.checks = append(r.checks, c)
|
||||
}
|
||||
|
||||
// RegisterFix associates a FixFunc with the given (checkID, fixID)
|
||||
// pair. A QuickFix with that ID can be advertised by any Finding
|
||||
// emitted by the matching check.
|
||||
func (r *Registry) RegisterFix(checkID, fixID string, fn FixFunc) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
r.fixes[fixKey(checkID, fixID)] = fn
|
||||
}
|
||||
|
||||
// RunAll executes every registered check and returns the results
|
||||
// in registration order. Each result's Severity is the highest
|
||||
// severity among its Findings, or SeverityOK when there are none.
|
||||
func (r *Registry) RunAll() []CheckResult {
|
||||
r.mu.RLock()
|
||||
checks := make([]Check, len(r.checks))
|
||||
copy(checks, r.checks)
|
||||
r.mu.RUnlock()
|
||||
|
||||
out := make([]CheckResult, 0, len(checks))
|
||||
|
||||
for _, c := range checks {
|
||||
findings := []Finding{}
|
||||
if c.Run != nil {
|
||||
findings = c.Run()
|
||||
}
|
||||
|
||||
out = append(out, CheckResult{
|
||||
ID: c.ID,
|
||||
Title: c.Title,
|
||||
Severity: rollupSeverity(findings),
|
||||
Findings: sortFindings(findings),
|
||||
})
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
// RunFix dispatches to the FixFunc registered for (checkID,
|
||||
// fixID). The returned string is forwarded as the user-facing
|
||||
// success message. ErrFixNotFound is returned when no fix is
|
||||
// registered.
|
||||
func (r *Registry) RunFix(checkID, fixID string, target Target) (string, error) {
|
||||
r.mu.RLock()
|
||||
fn, ok := r.fixes[fixKey(checkID, fixID)]
|
||||
r.mu.RUnlock()
|
||||
|
||||
if !ok {
|
||||
return "", fmt.Errorf("%w: %s/%s", ErrFixNotFound, checkID, fixID)
|
||||
}
|
||||
|
||||
return fn(target)
|
||||
}
|
||||
|
||||
func fixKey(checkID, fixID string) string {
|
||||
return checkID + "/" + fixID
|
||||
}
|
||||
|
||||
func rollupSeverity(findings []Finding) Severity {
|
||||
if len(findings) == 0 {
|
||||
return SeverityOK
|
||||
}
|
||||
|
||||
rank := map[Severity]int{
|
||||
SeverityOK: 0,
|
||||
SeverityInfo: 1,
|
||||
SeverityWarning: 2,
|
||||
SeverityError: 3,
|
||||
}
|
||||
|
||||
worst := SeverityInfo
|
||||
for i := range findings {
|
||||
if rank[findings[i].Severity] > rank[worst] {
|
||||
worst = findings[i].Severity
|
||||
}
|
||||
}
|
||||
|
||||
return worst
|
||||
}
|
||||
|
||||
func sortFindings(findings []Finding) []Finding {
|
||||
if len(findings) < 2 {
|
||||
return findings
|
||||
}
|
||||
|
||||
out := make([]Finding, len(findings))
|
||||
copy(out, findings)
|
||||
|
||||
rank := map[Severity]int{
|
||||
SeverityError: 0,
|
||||
SeverityWarning: 1,
|
||||
SeverityInfo: 2,
|
||||
SeverityOK: 3,
|
||||
}
|
||||
|
||||
sort.SliceStable(out, func(i, j int) bool {
|
||||
if rank[out[i].Severity] != rank[out[j].Severity] {
|
||||
return rank[out[i].Severity] < rank[out[j].Severity]
|
||||
}
|
||||
|
||||
if out[i].Target.Account != out[j].Target.Account {
|
||||
return out[i].Target.Account < out[j].Target.Account
|
||||
}
|
||||
|
||||
return out[i].Target.Device < out[j].Target.Device
|
||||
})
|
||||
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package health
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRegistry_RunAll_NoFindingsReturnsOK(t *testing.T) {
|
||||
r := NewRegistry()
|
||||
r.Register(Check{
|
||||
ID: "passes",
|
||||
Title: "Always passes",
|
||||
Run: func() []Finding { return nil },
|
||||
})
|
||||
|
||||
results := r.RunAll()
|
||||
if len(results) != 1 {
|
||||
t.Fatalf("expected 1 result, got %d", len(results))
|
||||
}
|
||||
|
||||
if results[0].Severity != SeverityOK {
|
||||
t.Errorf("expected SeverityOK for empty findings, got %q", results[0].Severity)
|
||||
}
|
||||
|
||||
if len(results[0].Findings) != 0 {
|
||||
t.Errorf("expected zero findings, got %d", len(results[0].Findings))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistry_RunAll_SeverityRollup(t *testing.T) {
|
||||
r := NewRegistry()
|
||||
r.Register(Check{
|
||||
ID: "mixed",
|
||||
Run: func() []Finding {
|
||||
return []Finding{
|
||||
{Severity: SeverityInfo, Message: "info"},
|
||||
{Severity: SeverityWarning, Message: "warn"},
|
||||
{Severity: SeverityError, Message: "err"},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
results := r.RunAll()
|
||||
if results[0].Severity != SeverityError {
|
||||
t.Errorf("expected SeverityError rollup, got %q", results[0].Severity)
|
||||
}
|
||||
|
||||
if results[0].Findings[0].Severity != SeverityError {
|
||||
t.Errorf("expected error to sort first, got %q", results[0].Findings[0].Severity)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistry_RunFix_Dispatch(t *testing.T) {
|
||||
r := NewRegistry()
|
||||
var captured Target
|
||||
r.RegisterFix("c1", "f1", func(t Target) (string, error) {
|
||||
captured = t
|
||||
return "applied", nil
|
||||
})
|
||||
|
||||
msg, err := r.RunFix("c1", "f1", Target{Account: "A", Device: "D"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if msg != "applied" {
|
||||
t.Errorf("unexpected message: %q", msg)
|
||||
}
|
||||
|
||||
if captured.Account != "A" || captured.Device != "D" {
|
||||
t.Errorf("target not propagated to fix: %+v", captured)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistry_RunFix_NotFound(t *testing.T) {
|
||||
r := NewRegistry()
|
||||
|
||||
_, err := r.RunFix("nope", "also-nope", Target{})
|
||||
if !errors.Is(err, ErrFixNotFound) {
|
||||
t.Errorf("expected ErrFixNotFound, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistry_Register_ReplacesByID(t *testing.T) {
|
||||
r := NewRegistry()
|
||||
r.Register(Check{ID: "x", Title: "first", Run: func() []Finding { return nil }})
|
||||
r.Register(Check{ID: "x", Title: "second", Run: func() []Finding { return nil }})
|
||||
|
||||
results := r.RunAll()
|
||||
if len(results) != 1 {
|
||||
t.Fatalf("expected 1 check after replace, got %d", len(results))
|
||||
}
|
||||
|
||||
if results[0].Title != "second" {
|
||||
t.Errorf("expected title to be replaced, got %q", results[0].Title)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user