mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-19 00:56:16 +00:00
Enhance interaction recording and analysis features
This commit is contained in:
@@ -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"`
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
<button class="tab-btn" onclick="openTab(event, 'tab-devices')">2. Devices</button>
|
||||
<button class="tab-btn" onclick="openTab(event, 'tab-sync')">3. Data Sync</button>
|
||||
<button class="tab-btn" onclick="openTab(event, 'tab-migration')">4. Migration</button>
|
||||
<button class="tab-btn" onclick="openTab(event, 'tab-interactions')">5. Interactions</button>
|
||||
</div>
|
||||
|
||||
<!-- Tab 0: Overview -->
|
||||
@@ -92,17 +93,22 @@
|
||||
<div style="margin-bottom: 20px;">
|
||||
<label for="discovery-interval">Discovery Interval:</label>
|
||||
<input type="text" id="discovery-interval" placeholder="5m" style="width: 100px;">
|
||||
<label style="margin-left: 15px;"><input type="checkbox" id="discovery-disabled"> Disable Automated Discovery</label>
|
||||
<label style="margin-left: 15px;"><input type="checkbox" id="discovery-enabled"> Enable Automated Discovery</label>
|
||||
</div>
|
||||
<div style="margin-bottom: 20px;">
|
||||
<button onclick="updateSettings()">Save Settings</button>
|
||||
<span id="settings-status" style="margin-left: 10px; font-size: 0.9em;"></span>
|
||||
</div>
|
||||
<div style="margin-bottom: 20px;">
|
||||
Proxy Logging:
|
||||
<label><input type="checkbox" id="proxy-redact" onchange="updateProxySettings()"> Redact Sensitive Headers</label>
|
||||
<label style="margin-left: 15px;"><input type="checkbox" id="proxy-log-body" onchange="updateProxySettings()"> Log Bodies</label>
|
||||
<label style="margin-left: 15px;"><input type="checkbox" id="proxy-record" onchange="updateProxySettings()"> Record Interactions</label>
|
||||
<strong>Proxy Logging:</strong>
|
||||
<div style="margin-top: 5px;">
|
||||
<label style="display: block; margin-bottom: 5px;"><input type="checkbox" id="proxy-redact" onchange="updateProxySettings()"> Redact Sensitive Headers</label>
|
||||
<label style="display: block; margin-bottom: 5px;"><input type="checkbox" id="proxy-log-body" onchange="updateProxySettings()"> Log Bodies</label>
|
||||
<label style="display: block; margin-bottom: 5px;">
|
||||
<input type="checkbox" id="proxy-record" onchange="updateProxySettings()"> Record Interactions
|
||||
<span style="font-size: 0.85em; color: #666; margin-left: 5px;">(View in <strong>5. Interactions</strong> tab)</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -272,6 +278,83 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab 5: Interactions -->
|
||||
<div id="tab-interactions" class="tab-content">
|
||||
<h2>Recorded Interactions</h2>
|
||||
<p>Analysis of traffic handled by this service (self) and proxied to Bose (upstream).</p>
|
||||
|
||||
<div id="interaction-stats-container" class="summary-box">
|
||||
<div style="display: flex; gap: 20px; align-items: center; margin-bottom: 15px;">
|
||||
<p style="margin: 0;">Total Requests: <strong id="total-requests">0</strong></p>
|
||||
<button onclick="fetchInteractionStats()">Refresh Stats</button>
|
||||
</div>
|
||||
<div style="display: flex; gap: 20px;">
|
||||
<div style="flex: 1; border-right: 1px solid #eee; padding-right: 20px;">
|
||||
<h3>By Service</h3>
|
||||
<ul id="stats-by-service" class="stats-list"></ul>
|
||||
</div>
|
||||
<div style="flex: 2;">
|
||||
<h3>Sessions</h3>
|
||||
<div id="stats-by-session-container" style="max-height: 200px; overflow-y: auto; border: 1px solid #eee; padding: 5px; border-radius: 4px;">
|
||||
<ul id="stats-by-session" class="stats-list"></ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="browse-recordings" class="summary-box" style="margin-top: 20px;">
|
||||
<h3>Browse Recordings</h3>
|
||||
<div style="margin-bottom: 15px; display: flex; gap: 15px; align-items: center; background: #f9f9f9; padding: 10px; border-radius: 4px;">
|
||||
<div>
|
||||
<label for="filter-session">Session:</label>
|
||||
<select id="filter-session" onchange="fetchInteractions()">
|
||||
<option value="">All Sessions</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="filter-category">Category:</label>
|
||||
<select id="filter-category" onchange="fetchInteractions()">
|
||||
<option value="">All Categories</option>
|
||||
<option value="self">Self (Emulated)</option>
|
||||
<option value="upstream">Upstream (Bose)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="filter-since">Since (YYYY-MM-DD HH:mm:ss):</label>
|
||||
<input type="text" id="filter-since" placeholder="e.g. 2026-02-15 15:00:00" size="25" onchange="fetchInteractions()">
|
||||
</div>
|
||||
<button onclick="fetchInteractions()">Apply Filters</button>
|
||||
</div>
|
||||
|
||||
<div id="interactions-list-container" style="max-height: 400px; overflow-y: auto;">
|
||||
<table style="width: 100%; border-collapse: collapse;">
|
||||
<thead>
|
||||
<tr style="text-align: left; border-bottom: 2px solid #eee;">
|
||||
<th style="padding: 8px;">#</th>
|
||||
<th style="padding: 8px;">Time</th>
|
||||
<th style="padding: 8px;">Method</th>
|
||||
<th style="padding: 8px;">Path</th>
|
||||
<th style="padding: 8px;">Status</th>
|
||||
<th style="padding: 8px;">Category</th>
|
||||
<th style="padding: 8px;">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="interactions-list">
|
||||
<tr><td colspan="7" style="padding: 20px; text-align: center; color: #666;">No interactions found.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="interaction-viewer" class="summary-box" style="margin-top: 20px; display: none; background: #2b2b2b; color: #a9b7c6;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;">
|
||||
<h3 style="margin: 0; color: #fff;">Recording Viewer: <span id="viewer-filename" style="font-weight: normal; font-size: 0.8em;"></span></h3>
|
||||
<button onclick="document.getElementById('interaction-viewer').style.display='none'" style="background: #444; color: #fff; border: 1px solid #666;">Close</button>
|
||||
</div>
|
||||
<pre id="interaction-content" style="white-space: pre-wrap; font-family: 'Courier New', Courier, monospace; font-size: 0.9em; margin: 0; padding: 10px; overflow-x: auto; max-height: 600px;"></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/web/js/script.js"></script>
|
||||
|
||||
@@ -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 = `<strong>${service || "unknown"}:</strong> ${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 = '<option value="">All Sessions</option>';
|
||||
|
||||
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 = `<span class="session-info"><strong>${sessionDisplay}:</strong> ${count || 0} requests</span> <button onclick="filterBySession('${session || ""}')" style="font-size: 0.8em; padding: 2px 5px;">Filter</button>`;
|
||||
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 = '<tr><td colspan="7" style="padding: 20px; text-align: center; color: #666;">No interactions found for current filters.</td></tr>';
|
||||
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 = `
|
||||
<td style="padding: 8px; color: #888;">${counter}</td>
|
||||
<td style="padding: 8px; font-size: 0.8em; white-space: nowrap;">${timestamp}</td>
|
||||
<td style="padding: 8px; font-family: monospace;">${method}</td>
|
||||
<td style="padding: 8px; font-size: 0.9em;">${path}</td>
|
||||
<td style="padding: 8px;"><span class="badge ${statusClass}">${status || '???'}</span></td>
|
||||
<td style="padding: 8px;"><span class="badge category-${category}">${category}</span></td>
|
||||
<td style="padding: 8px;"><button onclick="viewInteraction('${file}')">View</button></td>
|
||||
`;
|
||||
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;
|
||||
});
|
||||
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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/<session>/<category>/...
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user