diff --git a/.gitignore b/.gitignore
index 5ed88d5..b8081c4 100644
--- a/.gitignore
+++ b/.gitignore
@@ -19,6 +19,7 @@ dist/
/example-unified
/mdns-scanner
/websocket-demo
+/main
# Environment configuration
.env
@@ -28,6 +29,7 @@ docker-compose.override.yml
# Test coverage reports
coverage.out
+coverage*.out
coverage.html
*.prof
diff --git a/cmd/soundtouch-service/main.go b/cmd/soundtouch-service/main.go
index f25fe51..d6c753e 100644
--- a/cmd/soundtouch-service/main.go
+++ b/cmd/soundtouch-service/main.go
@@ -173,7 +173,7 @@ func main() {
persisted.LogBodies = config.logBody
persisted.RecordInteractions = config.record
persisted.DiscoveryInterval = config.discoveryInterval.String()
- persisted.DiscoveryDisabled = false
+ persisted.DiscoveryEnabled = true
persisted.Shortcuts = map[string]int{
"/.well-known/appspecific/com.chrome.devtools.json": http.StatusNotFound,
"/sw.js": http.StatusNotFound,
@@ -194,7 +194,7 @@ func main() {
server := handlers.NewServer(ds, sm, config.serverURL, config.redact, config.logBody, config.record)
server.SetHTTPServerURL(config.httpsServerURL)
server.SetVersionInfo(version, commit, date)
- server.SetDiscoverySettings(config.discoveryInterval, persisted.DiscoveryDisabled)
+ server.SetDiscoverySettings(config.discoveryInterval, persisted.DiscoveryEnabled)
server.SetShortcuts(persisted.Shortcuts)
for path, status := range persisted.Shortcuts {
@@ -443,8 +443,8 @@ func setupPythonProxy(targetURL string, redact, logBody bool, recorder *proxy.Re
func startDeviceDiscovery(server *handlers.Server) {
go func() {
for {
- currentInterval, disabled := server.GetDiscoverySettings()
- if !disabled {
+ currentInterval, enabled := server.GetDiscoverySettings()
+ if enabled {
server.DiscoverDevices(context.Background())
}
@@ -525,6 +525,9 @@ func setupRouter(server *handlers.Server, pyProxy *httputil.ReverseProxy) *chi.M
r.Get("/proxy-settings", server.HandleGetProxySettings)
r.Post("/proxy-settings", server.HandleUpdateProxySettings)
r.Get("/version", server.HandleGetVersionInfo)
+ r.Get("/interaction-stats", server.HandleGetInteractionStats)
+ r.Get("/interactions", server.HandleListInteractions)
+ r.Get("/interaction-content", server.HandleGetInteractionContent)
r.Get("/devices/{deviceId}/events", server.HandleGetDeviceEvents)
})
diff --git a/pkg/service/datastore/datastore.go b/pkg/service/datastore/datastore.go
index cce897d..5ed7ee8 100644
--- a/pkg/service/datastore/datastore.go
+++ b/pkg/service/datastore/datastore.go
@@ -705,7 +705,7 @@ type Settings struct {
LogBodies bool `json:"log_bodies"`
RecordInteractions bool `json:"record_interactions"`
DiscoveryInterval string `json:"discovery_interval,omitempty"`
- DiscoveryDisabled bool `json:"discovery_disabled"`
+ DiscoveryEnabled bool `json:"discovery_enabled"`
Shortcuts map[string]int `json:"shortcuts,omitempty"`
}
diff --git a/pkg/service/datastore/datastore_test.go b/pkg/service/datastore/datastore_test.go
index b99d845..0291a64 100644
--- a/pkg/service/datastore/datastore_test.go
+++ b/pkg/service/datastore/datastore_test.go
@@ -385,7 +385,7 @@ func TestSettingsPersistence(t *testing.T) {
ProxyURL: "http://myproxy:8001",
LogBodies: true,
DiscoveryInterval: "10m",
- DiscoveryDisabled: true,
+ DiscoveryEnabled: true,
}
err = ds.SaveSettings(settings)
@@ -407,7 +407,7 @@ func TestSettingsPersistence(t *testing.T) {
if loaded.DiscoveryInterval != settings.DiscoveryInterval {
t.Errorf("Expected DiscoveryInterval %s, got %s", settings.DiscoveryInterval, loaded.DiscoveryInterval)
}
- if loaded.DiscoveryDisabled != settings.DiscoveryDisabled {
- t.Errorf("Expected DiscoveryDisabled %v, got %v", settings.DiscoveryDisabled, loaded.DiscoveryDisabled)
+ if loaded.DiscoveryEnabled != settings.DiscoveryEnabled {
+ t.Errorf("Expected DiscoveryEnabled %v, got %v", settings.DiscoveryEnabled, loaded.DiscoveryEnabled)
}
}
diff --git a/pkg/service/handlers/handlers_setup.go b/pkg/service/handlers/handlers_setup.go
index c7630e9..2a08366 100644
--- a/pkg/service/handlers/handlers_setup.go
+++ b/pkg/service/handlers/handlers_setup.go
@@ -144,7 +144,7 @@ func (s *Server) HandleGetSettings(w http.ResponseWriter, _ *http.Request) {
s.mu.RLock()
serverURL, proxyURL, httpsServerURL := s.serverURL, s.proxyURL, s.httpsServerURL
discoveryInterval := s.discoveryInterval.String()
- discoveryDisabled := s.discoveryDisabled
+ discoveryEnabled := s.discoveryEnabled
s.mu.RUnlock()
if err := json.NewEncoder(w).Encode(map[string]interface{}{
@@ -152,7 +152,7 @@ func (s *Server) HandleGetSettings(w http.ResponseWriter, _ *http.Request) {
"proxy_url": proxyURL,
"https_server_url": httpsServerURL,
"discovery_interval": discoveryInterval,
- "discovery_disabled": discoveryDisabled,
+ "discovery_enabled": discoveryEnabled,
}); err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
@@ -165,7 +165,7 @@ func (s *Server) HandleUpdateSettings(w http.ResponseWriter, r *http.Request) {
ServerURL string `json:"server_url"`
ProxyURL string `json:"proxy_url"`
DiscoveryInterval string `json:"discovery_interval"`
- DiscoveryDisabled bool `json:"discovery_disabled"`
+ DiscoveryEnabled bool `json:"discovery_enabled"`
}
if err := json.NewDecoder(r.Body).Decode(&settings); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
@@ -186,7 +186,7 @@ func (s *Server) HandleUpdateSettings(w http.ResponseWriter, r *http.Request) {
s.discoveryInterval = interval
}
- s.discoveryDisabled = settings.DiscoveryDisabled
+ s.discoveryEnabled = settings.DiscoveryEnabled
if s.sm != nil {
s.sm.ServerURL = settings.ServerURL
@@ -208,7 +208,7 @@ func (s *Server) HandleUpdateSettings(w http.ResponseWriter, r *http.Request) {
LogBodies: currentLogBody,
RecordInteractions: currentRecord,
DiscoveryInterval: s.discoveryInterval.String(),
- DiscoveryDisabled: s.discoveryDisabled,
+ DiscoveryEnabled: s.discoveryEnabled,
})
s.mu.Unlock()
@@ -560,7 +560,7 @@ func (s *Server) HandleUpdateProxySettings(w http.ResponseWriter, r *http.Reques
// Access fields directly since we already hold the lock
serverURL, proxyURL, httpsServerURL := s.serverURL, s.proxyURL, s.httpsServerURL
discoveryInterval := s.discoveryInterval.String()
- discoveryDisabled := s.discoveryDisabled
+ discoveryEnabled := s.discoveryEnabled
log.Printf("Saving updated proxy settings to %s/settings.json", s.ds.DataDir)
err := s.ds.SaveSettings(datastore.Settings{
@@ -571,7 +571,7 @@ func (s *Server) HandleUpdateProxySettings(w http.ResponseWriter, r *http.Reques
LogBodies: s.proxyLogBody,
RecordInteractions: s.recordEnabled,
DiscoveryInterval: discoveryInterval,
- DiscoveryDisabled: discoveryDisabled,
+ DiscoveryEnabled: discoveryEnabled,
})
s.mu.Unlock()
@@ -740,3 +740,72 @@ func (s *Server) HandleGetVersionInfo(w http.ResponseWriter, _ *http.Request) {
return
}
}
+
+// HandleGetInteractionStats returns statistics about recorded interactions.
+func (s *Server) HandleGetInteractionStats(w http.ResponseWriter, _ *http.Request) {
+ if s.recorder == nil {
+ http.Error(w, "Recorder not initialized", http.StatusServiceUnavailable)
+ return
+ }
+
+ stats, err := s.recorder.GetInteractionStats()
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+
+ if err := json.NewEncoder(w).Encode(stats); err != nil {
+ http.Error(w, "Failed to encode response", http.StatusInternalServerError)
+ return
+ }
+}
+
+// HandleListInteractions returns a list of recorded interactions.
+func (s *Server) HandleListInteractions(w http.ResponseWriter, r *http.Request) {
+ if s.recorder == nil {
+ http.Error(w, "Recorder not initialized", http.StatusServiceUnavailable)
+ return
+ }
+
+ session := r.URL.Query().Get("session")
+ category := r.URL.Query().Get("category")
+ since := r.URL.Query().Get("since")
+
+ interactions, err := s.recorder.ListInteractions(session, category, since)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+
+ if err := json.NewEncoder(w).Encode(interactions); err != nil {
+ http.Error(w, "Failed to encode response", http.StatusInternalServerError)
+ return
+ }
+}
+
+// HandleGetInteractionContent returns the raw content of a recorded interaction.
+func (s *Server) HandleGetInteractionContent(w http.ResponseWriter, r *http.Request) {
+ if s.recorder == nil {
+ http.Error(w, "Recorder not initialized", http.StatusServiceUnavailable)
+ return
+ }
+
+ file := r.URL.Query().Get("file")
+ if file == "" {
+ http.Error(w, "File parameter is required", http.StatusBadRequest)
+ return
+ }
+
+ content, err := s.recorder.GetInteractionContent(file)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ w.Header().Set("Content-Type", "text/plain")
+ _, _ = w.Write(content)
+}
diff --git a/pkg/service/handlers/interactions_test.go b/pkg/service/handlers/interactions_test.go
new file mode 100644
index 0000000..ef0b58a
--- /dev/null
+++ b/pkg/service/handlers/interactions_test.go
@@ -0,0 +1,159 @@
+package handlers
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/gesellix/bose-soundtouch/pkg/service/datastore"
+ "github.com/gesellix/bose-soundtouch/pkg/service/proxy"
+ "github.com/go-chi/chi/v5"
+)
+
+func TestInteractionHandlers(t *testing.T) {
+ tmpDir, err := os.MkdirTemp("", "interaction-handlers-test")
+ if err != nil {
+ t.Fatalf("failed to create temp dir: %v", err)
+ }
+ defer os.RemoveAll(tmpDir)
+
+ ds := datastore.NewDataStore(filepath.Join(tmpDir, "test.db"))
+ server := &Server{ds: ds}
+
+ t.Run("HandleGetInteractionStats_NoRecorder", func(t *testing.T) {
+ req := httptest.NewRequest("GET", "/setup/interaction-stats", nil)
+ w := httptest.NewRecorder()
+ server.HandleGetInteractionStats(w, req)
+ if w.Code != http.StatusServiceUnavailable {
+ t.Errorf("Expected status 503, got %d", w.Code)
+ }
+ })
+
+ recorder := proxy.NewRecorder(tmpDir)
+ server.SetRecorder(recorder)
+
+ // Create a dummy interaction file
+ sessionID := recorder.SessionID
+ relPath := filepath.Join(sessionID, "self", "test", "0001-12-00-00.000-GET.http")
+ fullPath := filepath.Join(tmpDir, "interactions", relPath)
+ os.MkdirAll(filepath.Dir(fullPath), 0755)
+ os.WriteFile(fullPath, []byte("### GET /test\n\n> {% \n // Response: 200 OK\n%}\n"), 0644)
+
+ r := chi.NewRouter()
+ r.Route("/setup", func(r chi.Router) {
+ r.Get("/interaction-stats", server.HandleGetInteractionStats)
+ r.Get("/interactions", server.HandleListInteractions)
+ r.Get("/interaction-content", server.HandleGetInteractionContent)
+ })
+
+ t.Run("HandleGetInteractionStats", func(t *testing.T) {
+ req := httptest.NewRequest("GET", "/setup/interaction-stats", nil)
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Errorf("Expected status 200, got %d", w.Code)
+ }
+
+ var stats proxy.InteractionStats
+ if err := json.NewDecoder(w.Body).Decode(&stats); err != nil {
+ t.Fatalf("Failed to decode stats: %v", err)
+ }
+
+ if stats.TotalRequests != 1 {
+ t.Errorf("Expected 1 total request, got %d", stats.TotalRequests)
+ }
+ })
+
+ t.Run("HandleListInteractions", func(t *testing.T) {
+ req := httptest.NewRequest("GET", "/setup/interactions?category=self", nil)
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Errorf("Expected status 200, got %d", w.Code)
+ }
+
+ var interactions []proxy.Interaction
+ if err := json.NewDecoder(w.Body).Decode(&interactions); err != nil {
+ t.Fatalf("Failed to decode interactions: %v", err)
+ }
+
+ if len(interactions) != 1 {
+ t.Errorf("Expected 1 interaction, got %d", len(interactions))
+ }
+ })
+
+ t.Run("HandleGetInteractionContent", func(t *testing.T) {
+ req := httptest.NewRequest("GET", "/setup/interaction-content?file="+relPath, nil)
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Errorf("Expected status 200, got %d", w.Code)
+ }
+
+ if !strings.Contains(w.Body.String(), "### GET /test") {
+ t.Errorf("Unexpected content: %s", w.Body.String())
+ }
+ })
+
+ t.Run("HandleGetInteractionContent_MissingFile", func(t *testing.T) {
+ req := httptest.NewRequest("GET", "/setup/interaction-content", nil)
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+ if w.Code != http.StatusBadRequest {
+ t.Errorf("Expected status 400, got %d", w.Code)
+ }
+ })
+}
+
+func TestRecordMiddleware(t *testing.T) {
+ tmpDir, err := os.MkdirTemp("", "record-middleware-test")
+ if err != nil {
+ t.Fatalf("failed to create temp dir: %v", err)
+ }
+ defer os.RemoveAll(tmpDir)
+
+ ds := datastore.NewDataStore(filepath.Join(tmpDir, "test.db"))
+ server := &Server{
+ ds: ds,
+ recordEnabled: true,
+ }
+ recorder := proxy.NewRecorder(tmpDir)
+ server.SetRecorder(recorder)
+
+ r := chi.NewRouter()
+ r.Use(server.RecordMiddleware)
+ r.Get("/test-middleware", func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("X-Test", "Value")
+ w.WriteHeader(http.StatusCreated)
+ w.Write([]byte("created"))
+
+ if f, ok := w.(http.Flusher); ok {
+ f.Flush()
+ }
+ })
+
+ req := httptest.NewRequest("GET", "/test-middleware", nil)
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ if w.Code != http.StatusCreated {
+ t.Errorf("Expected status 201, got %d", w.Code)
+ }
+
+ t.Run("HandleRecordMiddleware_Disabled", func(t *testing.T) {
+ server.recordEnabled = false
+ req := httptest.NewRequest("GET", "/test-middleware", nil)
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+ if w.Code != http.StatusCreated {
+ t.Errorf("Expected status 201, got %d", w.Code)
+ }
+ })
+}
diff --git a/pkg/service/handlers/server.go b/pkg/service/handlers/server.go
index 8940b7e..dfc4e1d 100644
--- a/pkg/service/handlers/server.go
+++ b/pkg/service/handlers/server.go
@@ -26,7 +26,7 @@ type Server struct {
proxyLogBody bool
recordEnabled bool
discoveryInterval time.Duration
- discoveryDisabled bool
+ discoveryEnabled bool
shortcuts map[string]int
recorder *proxy.Recorder
Version string
@@ -59,12 +59,12 @@ func (s *Server) SetVersionInfo(version, commit, date string) {
}
// SetDiscoverySettings sets the discovery settings for the server.
-func (s *Server) SetDiscoverySettings(interval time.Duration, disabled bool) {
+func (s *Server) SetDiscoverySettings(interval time.Duration, enabled bool) {
s.mu.Lock()
defer s.mu.Unlock()
s.discoveryInterval = interval
- s.discoveryDisabled = disabled
+ s.discoveryEnabled = enabled
}
// SetShortcuts sets the request shortcuts for the server.
@@ -88,7 +88,7 @@ func (s *Server) GetDiscoverySettings() (time.Duration, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
- return s.discoveryInterval, s.discoveryDisabled
+ return s.discoveryInterval, s.discoveryEnabled
}
// SetHTTPServerURL sets the external HTTPS URL of the service.
diff --git a/pkg/service/handlers/web/css/style.css b/pkg/service/handlers/web/css/style.css
index eddc047..de568e1 100644
--- a/pkg/service/handlers/web/css/style.css
+++ b/pkg/service/handlers/web/css/style.css
@@ -4,7 +4,7 @@ th, td { border: 1px solid #ddd; padding: 8px; text-align: left; }
th { background-color: #f2f2f2; }
button { padding: 5px 10px; cursor: pointer; }
.status { margin-top: 10px; padding: 10px; border: 1px solid #ccc; display: none; }
-.summary-box { margin-top: 20px; padding: 15px; border: 1px solid #aaa; background-color: #f9f9f9; display: none; }
+.summary-box { margin-top: 20px; padding: 15px; border: 1px solid #aaa; background-color: #f9f9f9; }
pre { background-color: #eee; padding: 10px; overflow-x: auto; font-size: 12px; }
.diff-container { display: flex; gap: 10px; }
.diff-pane { flex: 1; min-width: 0; }
@@ -76,3 +76,29 @@ pre { background-color: #eee; padding: 10px; overflow-x: auto; font-size: 12px;
.btn-danger:hover {
background-color: #d32f2f;
}
+
+.badge {
+ padding: 2px 6px;
+ border-radius: 4px;
+ font-size: 0.85em;
+ font-weight: bold;
+}
+.stats-list {
+ list-style: none;
+ padding: 0;
+ margin: 0;
+}
+.stats-list li {
+ padding: 5px 0;
+ border-bottom: 1px solid #f0f0f0;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+}
+.stats-list li:last-child {
+ border-bottom: none;
+}
+.category-self { background-color: #e3f2fd; color: #0d47a1; }
+.category-upstream { background-color: #f3e5f5; color: #7b1fa2; }
+.status-success { background-color: #e8f5e9; color: #2e7d32; }
+.status-error { background-color: #ffebee; color: #c62828; }
diff --git a/pkg/service/handlers/web/index.html b/pkg/service/handlers/web/index.html
index edcdcea..70a3435 100644
--- a/pkg/service/handlers/web/index.html
+++ b/pkg/service/handlers/web/index.html
@@ -16,6 +16,7 @@
+
@@ -92,17 +93,22 @@
-
+
@@ -272,6 +278,83 @@
+
+
+
+
Recorded Interactions
+
Analysis of traffic handled by this service (self) and proxied to Bose (upstream).
+
+
+
+
Total Requests: 0
+
+
+
+
+
+
+
Browse Recordings
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | # |
+ Time |
+ Method |
+ Path |
+ Status |
+ Category |
+ Action |
+
+
+
+ | No interactions found. |
+
+
+
+
+
+
+
+
Recording Viewer:
+
+
+
+
+
diff --git a/pkg/service/handlers/web/js/script.js b/pkg/service/handlers/web/js/script.js
index 34a3a48..849eb9c 100644
--- a/pkg/service/handlers/web/js/script.js
+++ b/pkg/service/handlers/web/js/script.js
@@ -11,8 +11,8 @@ async function fetchSettings() {
if (settings.discovery_interval) {
document.getElementById('discovery-interval').value = settings.discovery_interval;
}
- if (settings.discovery_disabled !== undefined) {
- document.getElementById('discovery-disabled').checked = settings.discovery_disabled;
+ if (settings.discovery_enabled !== undefined) {
+ document.getElementById('discovery-enabled').checked = settings.discovery_enabled;
}
fetchProxySettings();
} catch (error) {
@@ -54,7 +54,7 @@ async function updateSettings() {
server_url: document.getElementById('target-domain').value,
proxy_url: document.getElementById('proxy-domain').value,
discovery_interval: document.getElementById('discovery-interval').value,
- discovery_disabled: document.getElementById('discovery-disabled').checked
+ discovery_enabled: document.getElementById('discovery-enabled').checked
};
const status = document.getElementById('settings-status');
status.innerText = 'Saving...';
@@ -168,6 +168,11 @@ function openTab(evt, tabId) {
content.className += " active";
}
+ if (tabId === 'tab-interactions') {
+ fetchInteractionStats();
+ fetchInteractions();
+ }
+
if (evt) {
evt.currentTarget.className += " active";
} else {
@@ -229,13 +234,202 @@ async function fetchVersion() {
}
}
+async function fetchInteractionStats() {
+ console.log('Fetching interaction stats...');
+ try {
+ const response = await fetch('/setup/interaction-stats');
+ if (!response.ok) {
+ throw new Error(`HTTP error! status: ${response.status}`);
+ }
+ const stats = await response.json();
+ console.log('Fetched interaction stats:', stats);
+
+ document.getElementById('total-requests').innerText = stats.total_requests || stats.TotalRequests || 0;
+
+ const statsContainer = document.getElementById('interaction-stats-container');
+ if (statsContainer) {
+ statsContainer.style.display = 'block';
+ }
+
+ const serviceList = document.getElementById('stats-by-service');
+ serviceList.innerHTML = '';
+ const byService = stats.by_service || stats.ByService;
+ if (byService) {
+ Object.entries(byService).forEach(([service, count]) => {
+ const li = document.createElement('li');
+ li.innerHTML = `${service || "unknown"}: ${count || 0} requests`;
+ serviceList.appendChild(li);
+ });
+ }
+
+ const sessionList = document.getElementById('stats-by-session');
+ const sessionFilter = document.getElementById('filter-session');
+ const currentFilter = sessionFilter.value;
+
+ sessionList.innerHTML = '';
+ sessionFilter.innerHTML = '';
+
+ const bySession = stats.by_session || stats.BySession;
+ if (bySession) {
+ // Sort by session ID (timestamp) descending
+ const sortedSessions = Object.entries(bySession)
+ .sort((a, b) => {
+ const sessionA = a[0] || "";
+ const sessionB = b[0] || "";
+ return sessionB.localeCompare(sessionA);
+ });
+
+ sortedSessions.forEach(([session, count]) => {
+ // Session format is like 20260215-160705-99213
+ // Try to make it more readable: 2026-02-15 16:07:05 (PID 99213)
+ let sessionDisplay = session || "unknown";
+ if (session && session.includes('-')) {
+ const parts = session.split('-');
+ if (parts.length >= 2) {
+ const date = parts[0]; // 20260215
+ const time = parts[1]; // 160705
+ if (date.length === 8 && time.length === 6) {
+ sessionDisplay = `${date.substring(0, 4)}-${date.substring(4, 6)}-${date.substring(6, 8)} ${time.substring(0, 2)}:${time.substring(2, 4)}:${time.substring(4, 6)}`;
+ if (parts.length >= 3) {
+ sessionDisplay += ` (PID ${parts[2]})`;
+ }
+ }
+ }
+ }
+
+ const li = document.createElement('li');
+ li.innerHTML = `${sessionDisplay}: ${count || 0} requests `;
+ sessionList.appendChild(li);
+
+ const opt = document.createElement('option');
+ opt.value = session || "";
+ opt.innerText = sessionDisplay;
+ sessionFilter.appendChild(opt);
+ });
+
+ sessionFilter.value = currentFilter;
+ }
+ } catch (error) {
+ console.error('Failed to fetch interaction stats', error);
+ }
+}
+
+function filterBySession(sessionId) {
+ document.getElementById('filter-session').value = sessionId;
+ fetchInteractions();
+ const browseContainer = document.getElementById('browse-recordings');
+ if (browseContainer) {
+ browseContainer.scrollIntoView({ behavior: 'smooth' });
+ }
+}
+
+async function fetchInteractions() {
+ console.log('Fetching interactions...');
+ const session = document.getElementById('filter-session').value;
+ const category = document.getElementById('filter-category').value;
+ const since = document.getElementById('filter-since').value;
+
+ let url = '/setup/interactions';
+ const params = [];
+ if (session) params.push(`session=${encodeURIComponent(session)}`);
+ if (category) params.push(`category=${encodeURIComponent(category)}`);
+ if (since) params.push(`since=${encodeURIComponent(since)}`);
+ if (params.length > 0) url += '?' + params.join('&');
+
+ try {
+ const response = await fetch(url);
+ if (!response.ok) {
+ throw new Error(`HTTP error! status: ${response.status}`);
+ }
+ const interactions = await response.json();
+ console.log('Fetched interactions:', interactions);
+ const list = document.getElementById('interactions-list');
+ if (!list) {
+ console.error('Could not find interactions-list element');
+ return;
+ }
+
+ // Show the parent summary box if it was hidden
+ const browseContainer = list.closest('.summary-box');
+ if (browseContainer) {
+ browseContainer.style.display = 'block';
+ }
+
+ list.innerHTML = '';
+
+ if (!interactions || interactions.length === 0) {
+ list.innerHTML = '| No interactions found for current filters. |
';
+ return;
+ }
+
+ // Default sort: Session desc, then Counter asc
+ // If a specific session is selected, sort primarily by counter asc
+ interactions.sort((a, b) => {
+ const sessionA = a.session || a.Session || "";
+ const sessionB = b.session || b.Session || "";
+ if (sessionA !== sessionB) {
+ return sessionB.localeCompare(sessionA);
+ }
+ const counterA = a.counter || a.Counter || 0;
+ const counterB = b.counter || b.Counter || 0;
+ return counterA - counterB;
+ });
+
+ interactions.forEach(i => {
+ const tr = document.createElement('tr');
+ tr.style.borderBottom = '1px solid #eee';
+
+ const counter = i.counter || i.Counter || 0;
+ const timestamp = i.timestamp || i.Timestamp || "";
+ const method = i.method || i.Method || "";
+ const path = i.path || i.Path || "";
+ const status = i.status || i.Status || "";
+ const category = i.category || i.Category || "";
+ const session = i.session || i.Session || "";
+ const file = i.file || i.File || "";
+
+ let statusClass = '';
+ if (status >= 200 && status < 300) statusClass = 'status-success';
+ else if (status >= 400) statusClass = 'status-error';
+
+ tr.innerHTML = `
+ ${counter} |
+ ${timestamp} |
+ ${method} |
+ ${path} |
+ ${status || '???'} |
+ ${category} |
+ |
+ `;
+ list.appendChild(tr);
+ });
+ } catch (error) {
+ console.error('Failed to fetch interactions', error);
+ }
+}
+
+async function viewInteraction(file) {
+ try {
+ const response = await fetch(`/setup/interaction-content?file=${encodeURIComponent(file)}`);
+ const content = await response.text();
+
+ document.getElementById('viewer-filename').innerText = file;
+ document.getElementById('interaction-content').innerText = content;
+ document.getElementById('interaction-viewer').style.display = 'block';
+ document.getElementById('interaction-viewer').scrollIntoView({ behavior: 'smooth' });
+ } catch (error) {
+ alert('Failed to load interaction content: ' + error);
+ }
+}
+
document.addEventListener('DOMContentLoaded', () => {
fetchSettings();
fetchDevices();
triggerDiscovery();
fetchVersion();
- document.getElementById('sync-now-btn').onclick = startSync;
+ const syncBtn = document.getElementById('sync-now-btn');
+ if (syncBtn) syncBtn.onclick = startSync;
});
diff --git a/pkg/service/proxy/proxy_test.go b/pkg/service/proxy/proxy_test.go
index 11c6620..1e89897 100644
--- a/pkg/service/proxy/proxy_test.go
+++ b/pkg/service/proxy/proxy_test.go
@@ -4,6 +4,7 @@ import (
"io"
"net/http/httptest"
"os"
+ "path/filepath"
"strings"
"testing"
)
@@ -64,6 +65,7 @@ func TestLoggingProxy_LogRequest(t *testing.T) {
defer func() { _ = os.Unsetenv("LOG_PROXY_BODY") }()
lp := NewLoggingProxy("http://example.com", true)
+ lp.LogBody = true
body := "test body content"
req := httptest.NewRequest("POST", "http://example.com/api", strings.NewReader(body))
@@ -77,4 +79,47 @@ func TestLoggingProxy_LogRequest(t *testing.T) {
if string(readBody) != body {
t.Errorf("Request body was consumed or changed, got %q, want %q", string(readBody), body)
}
+
+ // Test truncation
+ lp.MaxBodySize = 4
+ req2 := httptest.NewRequest("POST", "http://example.com/api", strings.NewReader("1234567890"))
+ req2.Header.Set("Content-Type", "text/plain")
+ lp.LogRequest(req2)
+}
+
+func TestLoggingProxy_LogResponse(t *testing.T) {
+ lp := NewLoggingProxy("http://example.com", true)
+ lp.LogBody = true
+
+ body := "response content"
+ req := httptest.NewRequest("GET", "http://example.com/api", nil)
+ w := httptest.NewRecorder()
+ w.Header().Set("Content-Type", "text/plain")
+ _, _ = w.WriteString(body)
+ res := w.Result()
+ res.Request = req
+
+ lp.LogResponse(res)
+
+ // Check if body is still readable
+ readBody, _ := io.ReadAll(res.Body)
+ if string(readBody) != body {
+ t.Errorf("Response body was consumed or changed, got %q, want %q", string(readBody), body)
+ }
+
+ // Test with recorder
+ tmpDir, _ := os.MkdirTemp("", "proxy-recorder-test")
+ defer os.RemoveAll(tmpDir)
+ recorder := NewRecorder(tmpDir)
+ lp.SetRecorder(recorder)
+ lp.RecordEnabled = true
+
+ lp.LogResponse(res)
+
+ // Verify recording exists
+ interactionsDir := filepath.Join(tmpDir, "interactions", recorder.SessionID, "upstream", "api")
+ files, _ := os.ReadDir(interactionsDir)
+ if len(files) == 0 {
+ t.Error("LogResponse did not record the interaction")
+ }
}
diff --git a/pkg/service/proxy/recorder.go b/pkg/service/proxy/recorder.go
index 7506412..052f9c5 100644
--- a/pkg/service/proxy/recorder.go
+++ b/pkg/service/proxy/recorder.go
@@ -26,6 +26,26 @@ type Recorder struct {
mu sync.Mutex
}
+// InteractionStats represents statistics for recorded interactions.
+type InteractionStats struct {
+ TotalRequests int `json:"total_requests"`
+ ByService map[string]int `json:"by_service"`
+ BySession map[string]int `json:"by_session"`
+}
+
+// Interaction represents a single recorded HTTP interaction.
+type Interaction struct {
+ ID string `json:"id"`
+ Session string `json:"session"`
+ Category string `json:"category"`
+ Method string `json:"method"`
+ Path string `json:"path"`
+ File string `json:"file"`
+ Counter int `json:"counter"`
+ Status int `json:"status"`
+ Timestamp string `json:"timestamp"`
+}
+
// NewRecorder creates a new HTTP interaction recorder.
func NewRecorder(baseDir string) *Recorder {
sessionID := time.Now().Format("20060102-150405") + "-" + fmt.Sprintf("%d", os.Getpid())
@@ -221,3 +241,194 @@ func (r *Recorder) updateEnvFile(newVars map[string]string) error {
return os.WriteFile(envFile, data, 0644)
}
+
+// GetInteractionStats returns statistics about recorded interactions.
+func (r *Recorder) GetInteractionStats() (*InteractionStats, error) {
+ stats := &InteractionStats{
+ ByService: make(map[string]int),
+ BySession: make(map[string]int),
+ }
+
+ interactionsDir := filepath.Join(r.BaseDir, "interactions")
+ if _, err := os.Stat(interactionsDir); os.IsNotExist(err) {
+ return stats, nil
+ }
+
+ err := filepath.Walk(interactionsDir, func(path string, info os.FileInfo, err error) error {
+ if err != nil {
+ return err
+ }
+
+ if !info.IsDir() && strings.HasSuffix(info.Name(), ".http") {
+ stats.TotalRequests++
+
+ // Extract category (self/upstream) and session from path
+ // Path is like: .../interactions///...
+ rel, err := filepath.Rel(interactionsDir, path)
+ if err != nil {
+ return err
+ }
+
+ parts := strings.Split(rel, string(filepath.Separator))
+ if len(parts) >= 2 {
+ sessionID := parts[0]
+ category := parts[1]
+ stats.BySession[sessionID]++
+ stats.ByService[category]++
+ }
+ }
+
+ return nil
+ })
+
+ return stats, err
+}
+
+// ListInteractions returns a list of recorded interactions.
+func (r *Recorder) ListInteractions(sessionFilter, categoryFilter, sinceFilter string) ([]Interaction, error) {
+ interactions := make([]Interaction, 0)
+ interactionsDir := filepath.Join(r.BaseDir, "interactions")
+
+ if _, err := os.Stat(interactionsDir); os.IsNotExist(err) {
+ return interactions, nil
+ }
+
+ err := filepath.Walk(interactionsDir, func(path string, info os.FileInfo, err error) error {
+ if err != nil {
+ return err
+ }
+
+ if info.IsDir() || !strings.HasSuffix(info.Name(), ".http") {
+ return nil
+ }
+
+ rel, err := filepath.Rel(interactionsDir, path)
+ if err != nil {
+ return err
+ }
+
+ parts := strings.Split(rel, string(filepath.Separator))
+ if len(parts) < 3 {
+ return nil
+ }
+
+ sessionID, category := parts[0], parts[1]
+ if (sessionFilter != "" && sessionID != sessionFilter) || (categoryFilter != "" && category != categoryFilter) {
+ return nil
+ }
+
+ interaction, ok := r.parseInteractionFile(rel, path, parts)
+ if !ok {
+ return nil
+ }
+
+ if sinceFilter != "" && interaction.Timestamp != "" {
+ fullTS := r.getFullTimestamp(sessionID, interaction.ID)
+
+ normalizedSince := strings.ReplaceAll(strings.ReplaceAll(sinceFilter, ":", "-"), " ", "-")
+ if fullTS != "" && fullTS < normalizedSince {
+ return nil
+ }
+ }
+
+ interactions = append(interactions, interaction)
+
+ return nil
+ })
+
+ return interactions, err
+}
+
+func (r *Recorder) parseInteractionFile(rel, path string, parts []string) (Interaction, bool) {
+ sessionID, category := parts[0], parts[1]
+ filename := parts[len(parts)-1]
+ fnParts := strings.Split(strings.TrimSuffix(filename, ".http"), "-")
+
+ date := ""
+ if len(sessionID) >= 8 {
+ date = sessionID[0:4] + "-" + sessionID[4:6] + "-" + sessionID[6:8]
+ }
+
+ timestamp := ""
+
+ if len(fnParts) >= 4 {
+ timeStr := fnParts[1] + ":" + fnParts[2] + ":" + fnParts[3]
+ timestamp = timeStr
+
+ if date != "" {
+ timestamp = date + " " + timeStr
+ }
+ }
+
+ requestPath := "/" + strings.Join(parts[2:len(parts)-1], "/")
+ if requestPath == "/root" {
+ requestPath = "/"
+ }
+
+ method, counter := "UNKNOWN", 0
+ if len(fnParts) >= 1 {
+ _, _ = fmt.Sscanf(fnParts[0], "%d", &counter)
+ }
+
+ if len(fnParts) >= 5 {
+ method = fnParts[4]
+ }
+
+ return Interaction{
+ ID: filename,
+ Session: sessionID,
+ Category: category,
+ Method: method,
+ Path: requestPath,
+ File: rel,
+ Counter: counter,
+ Status: r.peekStatus(path),
+ Timestamp: timestamp,
+ }, true
+}
+
+func (r *Recorder) getFullTimestamp(sessionID, filename string) string {
+ if len(sessionID) < 8 {
+ return ""
+ }
+
+ date := sessionID[0:4] + "-" + sessionID[4:6] + "-" + sessionID[6:8]
+ fnParts := strings.Split(strings.TrimSuffix(filename, ".http"), "-")
+
+ if len(fnParts) < 4 {
+ return ""
+ }
+
+ return date + "-" + fnParts[1] + "-" + fnParts[2] + "-" + fnParts[3]
+}
+
+func (r *Recorder) peekStatus(path string) int {
+ content, err := os.ReadFile(path)
+ if err != nil {
+ return 0
+ }
+
+ lines := strings.Split(string(content), "\n")
+ for _, line := range lines {
+ if !strings.Contains(line, "// Response:") {
+ continue
+ }
+
+ trimmedLine := strings.TrimPrefix(strings.TrimSpace(line), "//")
+ trimmedLine = strings.TrimPrefix(strings.TrimSpace(trimmedLine), "Response:")
+ trimmedLine = strings.TrimSpace(trimmedLine)
+
+ status := 0
+ _, _ = fmt.Sscanf(trimmedLine, "%d", &status)
+
+ return status
+ }
+
+ return 0
+}
+
+// GetInteractionContent returns the raw content of a recorded interaction.
+func (r *Recorder) GetInteractionContent(relPath string) ([]byte, error) {
+ fullPath := filepath.Join(r.BaseDir, "interactions", relPath)
+ return os.ReadFile(fullPath)
+}
diff --git a/pkg/service/proxy/recorder_test.go b/pkg/service/proxy/recorder_test.go
index 0939f50..bd492bf 100644
--- a/pkg/service/proxy/recorder_test.go
+++ b/pkg/service/proxy/recorder_test.go
@@ -1,7 +1,9 @@
package proxy
import (
+ "bytes"
"encoding/json"
+ "io"
"net/http"
"net/url"
"os"
@@ -339,3 +341,362 @@ func TestRecorder_EnvFile(t *testing.T) {
t.Errorf("Expected ip to be 192.168.178.35, got %s", content["session"]["ip"])
}
}
+
+func TestRecorder_GetInteractionStats(t *testing.T) {
+ tmpDir, err := os.MkdirTemp("", "recorder-stats-test")
+ if err != nil {
+ t.Fatalf("failed to create temp dir: %v", err)
+ }
+ defer os.RemoveAll(tmpDir)
+
+ r := NewRecorder(tmpDir)
+ r.SessionID = "20260215-120000-12345"
+
+ // Create some dummy interactions
+ files := []string{
+ "interactions/20260215-120000-12345/self/setup/0001-12-00-01.000-GET.http",
+ "interactions/20260215-120000-12345/upstream/marge/0002-12-00-02.000-POST.http",
+ "interactions/20260215-130000-67890/self/setup/0001-13-00-01.000-GET.http",
+ }
+
+ for _, f := range files {
+ path := filepath.Join(tmpDir, f)
+ os.MkdirAll(filepath.Dir(path), 0755)
+ os.WriteFile(path, []byte("test"), 0644)
+ }
+
+ stats, err := r.GetInteractionStats()
+ if err != nil {
+ t.Fatalf("GetInteractionStats failed: %v", err)
+ }
+
+ if stats.TotalRequests != 3 {
+ t.Errorf("Expected 3 total requests, got %d", stats.TotalRequests)
+ }
+
+ if stats.ByService["self"] != 2 {
+ t.Errorf("Expected 2 self requests, got %d", stats.ByService["self"])
+ }
+
+ if stats.ByService["upstream"] != 1 {
+ t.Errorf("Expected 1 upstream request, got %d", stats.ByService["upstream"])
+ }
+
+ if stats.BySession["20260215-120000-12345"] != 2 {
+ t.Errorf("Expected 2 requests for session 1, got %d", stats.BySession["20260215-120000-12345"])
+ }
+}
+
+func TestRecorder_ListInteractions(t *testing.T) {
+ tmpDir, err := os.MkdirTemp("", "recorder-list-test")
+ if err != nil {
+ t.Fatalf("failed to create temp dir: %v", err)
+ }
+ defer os.RemoveAll(tmpDir)
+
+ r := NewRecorder(tmpDir)
+ session1 := "20260215-120000-12345"
+ session2 := "20260215-130000-67890"
+
+ // Create some dummy interactions
+ files := []struct {
+ path string
+ content string
+ }{
+ {
+ path: filepath.Join("interactions", session1, "self", "setup", "0001-12-00-01.555-GET.http"),
+ content: "### GET /setup\n\n> {% \n // Response: 200 OK\n%}\n",
+ },
+ {
+ path: filepath.Join("interactions", session1, "upstream", "marge", "0002-12-00-02.000-POST.http"),
+ content: "### POST /marge\n\n> {% \n // Response: 201 Created\n%}\n",
+ },
+ {
+ path: filepath.Join("interactions", session2, "self", "info", "0001-13-00-05.000-GET.http"),
+ content: "### GET /info\n\n> {% \n // Response: 404 Not Found\n%}\n",
+ },
+ }
+
+ for _, f := range files {
+ path := filepath.Join(tmpDir, f.path)
+ os.MkdirAll(filepath.Dir(path), 0755)
+ os.WriteFile(path, []byte(f.content), 0644)
+ }
+
+ t.Run("List_all", func(t *testing.T) {
+ list, err := r.ListInteractions("", "", "")
+ if err != nil {
+ t.Fatalf("ListInteractions failed: %v", err)
+ }
+ if len(list) != 3 {
+ t.Errorf("Expected 3 interactions, got %d", len(list))
+ }
+ })
+
+ t.Run("Filter_by_session", func(t *testing.T) {
+ list, err := r.ListInteractions(session1, "", "")
+ if err != nil {
+ t.Fatalf("ListInteractions failed: %v", err)
+ }
+ if len(list) != 2 {
+ t.Errorf("Expected 2 interactions for session1, got %d", len(list))
+ }
+ for _, i := range list {
+ if i.Session != session1 {
+ t.Errorf("Expected session %s, got %s", session1, i.Session)
+ }
+ }
+ })
+
+ t.Run("Filter_by_category", func(t *testing.T) {
+ list, err := r.ListInteractions("", "upstream", "")
+ if err != nil {
+ t.Fatalf("ListInteractions failed: %v", err)
+ }
+ if len(list) != 1 {
+ t.Errorf("Expected 1 upstream interaction, got %d", len(list))
+ }
+ if list[0].Category != "upstream" {
+ t.Errorf("Expected category upstream, got %s", list[0].Category)
+ }
+ })
+
+ t.Run("Check_enhanced_fields", func(t *testing.T) {
+ list, err := r.ListInteractions(session1, "self", "")
+ if err != nil {
+ t.Fatalf("ListInteractions failed: %v", err)
+ }
+ if len(list) == 0 {
+ t.Fatal("Expected at least one interaction")
+ }
+ i := list[0]
+ if i.Counter != 1 {
+ t.Errorf("Expected counter 1, got %d", i.Counter)
+ }
+ if i.Status != 200 {
+ t.Errorf("Expected status 200, got %d", i.Status)
+ }
+ if i.Method != "GET" {
+ t.Errorf("Expected method GET, got %s", i.Method)
+ }
+ if i.Timestamp != "2026-02-15 12:00:01.555" {
+ t.Errorf("Expected timestamp 2026-02-15 12:00:01.555, got %s", i.Timestamp)
+ }
+ if i.Path != "/setup" {
+ t.Errorf("Expected path /setup, got %s", i.Path)
+ }
+ })
+
+ t.Run("Filter_by_since", func(t *testing.T) {
+ // session1 has 2026-02-15 12:00:01.555 and 12:00:02.000
+ // session2 has 2026-02-15 13:00:05.000
+ list, err := r.ListInteractions("", "", "2026-02-15 12:30:00")
+ if err != nil {
+ t.Fatalf("ListInteractions failed: %v", err)
+ }
+ if len(list) != 1 {
+ t.Errorf("Expected 1 interaction since 12:30:00, got %d", len(list))
+ }
+ if list[0].Session != session2 {
+ t.Errorf("Expected session2, got %s", list[0].Session)
+ }
+
+ list, err = r.ListInteractions("", "", "2026-02-15 12:00:01.600")
+ if err != nil {
+ t.Fatalf("ListInteractions failed: %v", err)
+ }
+ // Should include 12:00:02.000 and 13:00:05.000
+ if len(list) != 2 {
+ t.Errorf("Expected 2 interactions since 12:00:01.600, got %d", len(list))
+ }
+ })
+}
+
+func TestRecorder_GetInteractionContent(t *testing.T) {
+ tmpDir, err := os.MkdirTemp("", "recorder-content-test")
+ if err != nil {
+ t.Fatalf("failed to create temp dir: %v", err)
+ }
+ defer os.RemoveAll(tmpDir)
+
+ r := NewRecorder(tmpDir)
+ relPath := filepath.Join(r.SessionID, "self", "test", "0001-GET.http")
+ fullPath := filepath.Join(tmpDir, "interactions", relPath)
+ os.MkdirAll(filepath.Dir(fullPath), 0755)
+
+ expectedContent := "test content"
+ os.WriteFile(fullPath, []byte(expectedContent), 0644)
+
+ content, err := r.GetInteractionContent(relPath)
+ if err != nil {
+ t.Fatalf("GetInteractionContent failed: %v", err)
+ }
+
+ if string(content) != expectedContent {
+ t.Errorf("Expected %s, got %s", expectedContent, string(content))
+ }
+
+ _, err = r.GetInteractionContent("non-existent")
+ if err == nil {
+ t.Error("Expected error for non-existent file, got nil")
+ }
+}
+
+func TestRecorder_Record_FullExchange(t *testing.T) {
+ tmpDir, err := os.MkdirTemp("", "recorder-full-test")
+ if err != nil {
+ t.Fatalf("failed to create temp dir: %v", err)
+ }
+ defer os.RemoveAll(tmpDir)
+
+ r := NewRecorder(tmpDir)
+
+ req := &http.Request{
+ Method: "POST",
+ URL: &url.URL{
+ Path: "/test",
+ },
+ Header: make(http.Header),
+ Body: io.NopCloser(strings.NewReader("request body")),
+ }
+ req.Header.Set("Content-Type", "text/plain")
+
+ res := &http.Response{
+ StatusCode: 200,
+ Header: make(http.Header),
+ Body: io.NopCloser(strings.NewReader("response body")),
+ Request: req,
+ }
+ res.Header.Set("Content-Type", "application/json")
+
+ err = r.Record("self", req, res)
+ if err != nil {
+ t.Fatalf("Record failed: %v", err)
+ }
+
+ // Verify file content
+ interactionsDir := filepath.Join(tmpDir, "interactions", r.SessionID, "self", "test")
+ files, _ := os.ReadDir(interactionsDir)
+ if len(files) == 0 {
+ t.Fatal("No recording file found")
+ }
+
+ content, _ := os.ReadFile(filepath.Join(interactionsDir, files[0].Name()))
+ contentStr := string(content)
+
+ if !strings.Contains(contentStr, "request body") {
+ t.Error("Recording does not contain request body")
+ }
+ if !strings.Contains(contentStr, "Response: 200 OK") {
+ t.Error("Recording does not contain response status")
+ }
+ if !strings.Contains(contentStr, "response body") {
+ t.Error("Recording does not contain response body")
+ }
+}
+
+func TestRecorder_Record_BinaryResponse(t *testing.T) {
+ tmpDir, err := os.MkdirTemp("", "recorder-binary-test")
+ if err != nil {
+ t.Fatalf("failed to create temp dir: %v", err)
+ }
+ defer os.RemoveAll(tmpDir)
+
+ r := NewRecorder(tmpDir)
+
+ req := &http.Request{
+ Method: "GET",
+ URL: &url.URL{Path: "/image"},
+ }
+
+ res := &http.Response{
+ StatusCode: 200,
+ Header: make(http.Header),
+ Body: io.NopCloser(bytes.NewBuffer([]byte{0x00, 0x01, 0x02, 0x03})),
+ Request: req,
+ }
+ res.Header.Set("Content-Type", "image/png")
+
+ err = r.Record("self", req, res)
+ if err != nil {
+ t.Fatalf("Record failed: %v", err)
+ }
+
+ interactionsDir := filepath.Join(tmpDir, "interactions", r.SessionID, "self", "image")
+ files, _ := os.ReadDir(interactionsDir)
+ content, _ := os.ReadFile(filepath.Join(interactionsDir, files[0].Name()))
+ contentStr := string(content)
+
+ if !strings.Contains(contentStr, "[Binary response body: 4 bytes]") {
+ t.Error("Recording does not correctly report binary response")
+ }
+}
+
+func TestRecorder_ListInteractions_FullTimestamp(t *testing.T) {
+ tmpDir, err := os.MkdirTemp("", "recorder-full-ts-test")
+ if err != nil {
+ t.Fatalf("failed to create temp dir: %v", err)
+ }
+ defer os.RemoveAll(tmpDir)
+
+ r := NewRecorder(tmpDir)
+ sessionID := "20260215-100000-12345"
+ r.SessionID = sessionID
+
+ // Create some dummy recordings
+ basePath := filepath.Join(tmpDir, "interactions", sessionID, "self", "test")
+ os.MkdirAll(basePath, 0755)
+
+ files := []string{
+ "0001-10-00-01.000-GET.http",
+ "0002-11-00-00.000-GET.http",
+ }
+
+ for _, f := range files {
+ os.WriteFile(filepath.Join(basePath, f), []byte("test"), 0644)
+ }
+
+ t.Run("Check_Full_Timestamp_Display", func(t *testing.T) {
+ interactions, err := r.ListInteractions(sessionID, "", "")
+ if err != nil {
+ t.Fatalf("ListInteractions failed: %v", err)
+ }
+
+ if len(interactions) != 2 {
+ t.Fatalf("Expected 2 interactions, got %d", len(interactions))
+ }
+
+ expectedTS := "2026-02-15 10:00:01.000"
+ if interactions[0].Timestamp != expectedTS {
+ t.Errorf("Expected timestamp %s, got %s", expectedTS, interactions[0].Timestamp)
+ }
+ })
+
+ t.Run("Filter_By_Full_Date_Time", func(t *testing.T) {
+ // Filter for interactions since 10:30:00 on that day
+ interactions, err := r.ListInteractions(sessionID, "", "2026-02-15 10:30:00")
+ if err != nil {
+ t.Fatalf("ListInteractions failed: %v", err)
+ }
+
+ if len(interactions) != 1 {
+ t.Fatalf("Expected 1 interaction, got %d", len(interactions))
+ }
+
+ if interactions[0].ID != "0002-11-00-00.000-GET.http" {
+ t.Errorf("Expected 0002-..., got %s", interactions[0].ID)
+ }
+ })
+
+ t.Run("Filter_By_Date_Only", func(t *testing.T) {
+ // Filter for interactions since the day before
+ interactions, err := r.ListInteractions(sessionID, "", "2026-02-14")
+ if err != nil {
+ t.Fatalf("ListInteractions failed: %v", err)
+ }
+
+ if len(interactions) != 2 {
+ t.Fatalf("Expected 2 interactions, got %d", len(interactions))
+ }
+ })
+}