19eddba1ee
Changes: - RTSP now generates single URL based on credentials availability * With credentials: only rtsp://user:pass@host/path * Without credentials: only rtsp://host/path - HTTP/HTTPS unchanged: still generates 4 auth variants - Improved deduplication efficiency from 66% to 100% for RTSP - Added comprehensive test coverage for protocol auth behavior This reduces unnecessary stream testing and improves discovery speed
370 lines
10 KiB
Go
370 lines
10 KiB
Go
package stream
|
||
|
||
import (
|
||
"strings"
|
||
"testing"
|
||
|
||
"github.com/eduard256/Strix/internal/models"
|
||
)
|
||
|
||
// mockLogger implements the logger interface for testing
|
||
type mockLogger struct{}
|
||
|
||
func (m *mockLogger) Debug(msg string, args ...any) {}
|
||
func (m *mockLogger) Error(msg string, err error, args ...any) {}
|
||
|
||
// TestCurrentDeduplicationProblems демонстрирует проблемы текущей дедупликации
|
||
func TestCurrentDeduplicationProblems(t *testing.T) {
|
||
logger := &mockLogger{}
|
||
builder := NewBuilder([]string{}, logger)
|
||
|
||
tests := []struct {
|
||
name string
|
||
entry models.CameraEntry
|
||
ctx BuildContext
|
||
expectedURLCount int // Сколько Builder генерирует
|
||
realUniqueCount int // Сколько реально уникальных
|
||
description string
|
||
}{
|
||
{
|
||
name: "HTTP auth variants - same endpoint, 4 different URLs",
|
||
entry: models.CameraEntry{
|
||
Type: "JPEG",
|
||
Protocol: "http",
|
||
Port: 80,
|
||
URL: "snapshot.jpg",
|
||
},
|
||
ctx: BuildContext{
|
||
IP: "192.168.1.100",
|
||
Username: "admin",
|
||
Password: "12345",
|
||
Port: 80,
|
||
},
|
||
expectedURLCount: 4, // Builder генерирует 4 варианта
|
||
realUniqueCount: 1, // Но это ОДИН поток
|
||
description: "PROBLEM: 4 authentication variants of the same HTTP endpoint",
|
||
},
|
||
{
|
||
name: "HTTP with auth placeholders - generates duplicates",
|
||
entry: models.CameraEntry{
|
||
Type: "JPEG",
|
||
Protocol: "http",
|
||
Port: 80,
|
||
URL: "snapshot.cgi?user=[USERNAME]&pwd=[PASSWORD]",
|
||
},
|
||
ctx: BuildContext{
|
||
IP: "192.168.1.100",
|
||
Username: "admin",
|
||
Password: "12345",
|
||
Port: 80,
|
||
},
|
||
expectedURLCount: 4,
|
||
realUniqueCount: 1,
|
||
description: "PROBLEM: Placeholder replacement + auth variants = duplicates",
|
||
},
|
||
{
|
||
name: "RTSP with credentials - now FIXED",
|
||
entry: models.CameraEntry{
|
||
Type: "FFMPEG",
|
||
Protocol: "rtsp",
|
||
Port: 554,
|
||
URL: "/live/main",
|
||
},
|
||
ctx: BuildContext{
|
||
IP: "192.168.1.100",
|
||
Username: "admin",
|
||
Password: "12345",
|
||
Port: 554,
|
||
},
|
||
expectedURLCount: 1, // FIXED: только с credentials
|
||
realUniqueCount: 1, // Это один поток
|
||
description: "FIXED: RTSP with credentials generates ONLY auth URL",
|
||
},
|
||
{
|
||
name: "RTSP without credentials - only one URL",
|
||
entry: models.CameraEntry{
|
||
Type: "FFMPEG",
|
||
Protocol: "rtsp",
|
||
Port: 554,
|
||
URL: "/live/main",
|
||
},
|
||
ctx: BuildContext{
|
||
IP: "192.168.1.100",
|
||
Username: "",
|
||
Password: "",
|
||
Port: 554,
|
||
},
|
||
expectedURLCount: 1,
|
||
realUniqueCount: 1,
|
||
description: "OK: No credentials = only one URL",
|
||
},
|
||
}
|
||
|
||
for _, tt := range tests {
|
||
t.Run(tt.name, func(t *testing.T) {
|
||
urls := builder.BuildURLsFromEntry(tt.entry, tt.ctx)
|
||
|
||
t.Logf("\n=== %s ===", tt.description)
|
||
t.Logf("Entry: %s://%s", tt.entry.Protocol, tt.entry.URL)
|
||
t.Logf("Expected URL count: %d", tt.expectedURLCount)
|
||
t.Logf("Real unique streams: %d", tt.realUniqueCount)
|
||
t.Logf("Generated URLs:")
|
||
for i, url := range urls {
|
||
t.Logf(" [%d] %s", i+1, url)
|
||
}
|
||
|
||
if len(urls) != tt.expectedURLCount {
|
||
t.Errorf("FAILED: Expected %d URLs, got %d", tt.expectedURLCount, len(urls))
|
||
}
|
||
|
||
// Демонстрация проблемы
|
||
if len(urls) > tt.realUniqueCount {
|
||
duplicateCount := len(urls) - tt.realUniqueCount
|
||
t.Logf("\n⚠️ PROBLEM: %d semantic duplicates generated", duplicateCount)
|
||
t.Logf("These are different URL strings pointing to the SAME stream!")
|
||
t.Logf("Waste: %d unnecessary tests", duplicateCount)
|
||
} else if len(urls) == tt.realUniqueCount && tt.expectedURLCount == tt.realUniqueCount {
|
||
t.Logf("\n✓ NO DUPLICATES: All URLs are unique (FIXED!)")
|
||
}
|
||
|
||
// Показать канонические URL
|
||
canonicalURLs := make(map[string][]string)
|
||
for _, url := range urls {
|
||
canonical := makeCanonical(url)
|
||
canonicalURLs[canonical] = append(canonicalURLs[canonical], url)
|
||
}
|
||
|
||
t.Logf("\nCanonical URL analysis:")
|
||
for canonical, variants := range canonicalURLs {
|
||
t.Logf(" Canonical: %s", canonical)
|
||
if len(variants) > 1 {
|
||
t.Logf(" ⚠️ Has %d variants (DUPLICATES!):", len(variants))
|
||
for _, v := range variants {
|
||
t.Logf(" - %s", v)
|
||
}
|
||
} else {
|
||
t.Logf(" ✓ Unique")
|
||
}
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// TestMultipleSourcesDuplication тестирует дубликаты от разных источников
|
||
func TestMultipleSourcesDuplication(t *testing.T) {
|
||
logger := &mockLogger{}
|
||
builder := NewBuilder([]string{}, logger)
|
||
|
||
// Симуляция: один и тот же паттерн из двух источников
|
||
entry1 := models.CameraEntry{
|
||
Type: "FFMPEG",
|
||
Protocol: "rtsp",
|
||
Port: 554,
|
||
URL: "/Streaming/Channels/101",
|
||
}
|
||
|
||
entry2 := models.CameraEntry{
|
||
Type: "FFMPEG",
|
||
Protocol: "rtsp",
|
||
Port: 554,
|
||
URL: "/Streaming/Channels/101",
|
||
}
|
||
|
||
ctx := BuildContext{
|
||
IP: "192.168.1.100",
|
||
Username: "admin",
|
||
Password: "12345",
|
||
Port: 554,
|
||
}
|
||
|
||
urls1 := builder.BuildURLsFromEntry(entry1, ctx)
|
||
urls2 := builder.BuildURLsFromEntry(entry2, ctx)
|
||
|
||
t.Logf("\n=== Multiple Sources Generate Same URLs ===")
|
||
t.Logf("Source 1 (e.g., Popular Patterns):")
|
||
for i, url := range urls1 {
|
||
t.Logf(" [%d] %s", i+1, url)
|
||
}
|
||
|
||
t.Logf("\nSource 2 (e.g., Model Patterns):")
|
||
for i, url := range urls2 {
|
||
t.Logf(" [%d] %s", i+1, url)
|
||
}
|
||
|
||
// Симуляция текущей дедупликации (простое сравнение строк)
|
||
urlMap := make(map[string]bool)
|
||
var combined []string
|
||
|
||
for _, url := range urls1 {
|
||
if !urlMap[url] {
|
||
combined = append(combined, url)
|
||
urlMap[url] = true
|
||
}
|
||
}
|
||
|
||
detectedDuplicates := 0
|
||
for _, url := range urls2 {
|
||
if !urlMap[url] {
|
||
combined = append(combined, url)
|
||
urlMap[url] = true
|
||
} else {
|
||
detectedDuplicates++
|
||
}
|
||
}
|
||
|
||
t.Logf("\nCurrent deduplication results:")
|
||
t.Logf(" Source 1 URLs: %d", len(urls1))
|
||
t.Logf(" Source 2 URLs: %d", len(urls2))
|
||
t.Logf(" Combined URLs: %d", len(combined))
|
||
t.Logf(" Duplicates detected by string comparison: %d", detectedDuplicates)
|
||
|
||
// Канонический анализ
|
||
canonicalMap := make(map[string][]string)
|
||
for _, url := range combined {
|
||
canonical := makeCanonical(url)
|
||
canonicalMap[canonical] = append(canonicalMap[canonical], url)
|
||
}
|
||
|
||
realUnique := len(canonicalMap)
|
||
semanticDuplicates := len(combined) - realUnique
|
||
|
||
t.Logf("\nCanonical URL analysis:")
|
||
t.Logf(" Real unique streams: %d", realUnique)
|
||
t.Logf(" Semantic duplicates: %d", semanticDuplicates)
|
||
t.Logf(" Current dedup effectiveness: %.1f%%",
|
||
float64(detectedDuplicates)/float64(len(urls1)+len(urls2))*100)
|
||
t.Logf(" Should be dedup effectiveness: %.1f%%",
|
||
float64(semanticDuplicates+detectedDuplicates)/float64(len(urls1)+len(urls2))*100)
|
||
|
||
if semanticDuplicates > 0 {
|
||
t.Logf("\n⚠️ PROBLEM: %d semantic duplicates NOT detected", semanticDuplicates)
|
||
}
|
||
}
|
||
|
||
// TestWorstCaseScenario показывает худший сценарий
|
||
func TestWorstCaseScenario(t *testing.T) {
|
||
logger := &mockLogger{}
|
||
builder := NewBuilder([]string{}, logger)
|
||
|
||
// Паттерн, который есть везде: Popular + Model + ONVIF
|
||
entry := models.CameraEntry{
|
||
Type: "JPEG",
|
||
Protocol: "http",
|
||
Port: 80,
|
||
URL: "snapshot.jpg",
|
||
}
|
||
|
||
ctx := BuildContext{
|
||
IP: "192.168.1.100",
|
||
Username: "admin",
|
||
Password: "12345",
|
||
Port: 80,
|
||
}
|
||
|
||
// Симуляция 3 источников
|
||
popularURLs := builder.BuildURLsFromEntry(entry, ctx)
|
||
modelURLs := builder.BuildURLsFromEntry(entry, ctx)
|
||
|
||
// ONVIF может вернуть URL без credentials
|
||
onvifURL := "http://192.168.1.100/snapshot.jpg"
|
||
|
||
t.Logf("\n=== WORST CASE: Same pattern from 3 sources ===")
|
||
t.Logf("Popular patterns generates: %d URLs", len(popularURLs))
|
||
t.Logf("Model patterns generates: %d URLs", len(modelURLs))
|
||
t.Logf("ONVIF returns: 1 URL")
|
||
|
||
// Текущая дедупликация
|
||
urlMap := make(map[string]bool)
|
||
var all []string
|
||
|
||
add := func(url string) {
|
||
if !urlMap[url] {
|
||
all = append(all, url)
|
||
urlMap[url] = true
|
||
}
|
||
}
|
||
|
||
for _, url := range popularURLs {
|
||
add(url)
|
||
}
|
||
for _, url := range modelURLs {
|
||
add(url)
|
||
}
|
||
add(onvifURL)
|
||
|
||
t.Logf("\nAfter current deduplication:")
|
||
t.Logf(" Total URLs to test: %d", len(all))
|
||
|
||
for i, url := range all {
|
||
t.Logf(" [%d] %s", i+1, url)
|
||
}
|
||
|
||
// Канонический анализ
|
||
canonicalMap := make(map[string][]string)
|
||
for _, url := range all {
|
||
canonical := makeCanonical(url)
|
||
canonicalMap[canonical] = append(canonicalMap[canonical], url)
|
||
}
|
||
|
||
t.Logf("\nCanonical analysis:")
|
||
t.Logf(" Real unique streams: %d", len(canonicalMap))
|
||
t.Logf(" URLs being tested: %d", len(all))
|
||
t.Logf(" Waste: %d unnecessary tests (%.1f%%)",
|
||
len(all)-len(canonicalMap),
|
||
float64(len(all)-len(canonicalMap))/float64(len(all))*100)
|
||
|
||
if len(all) > 1 {
|
||
t.Logf("\n⚠️ CRITICAL: Testing the same stream %d times!", len(all))
|
||
t.Logf("Expected time waste: ~%d seconds (assuming 2s per test)", (len(all)-1)*2)
|
||
}
|
||
}
|
||
|
||
// makeCanonical - упрощенная нормализация URL для теста
|
||
func makeCanonical(rawURL string) string {
|
||
url := rawURL
|
||
|
||
// 1. Убрать credentials (user:pass@)
|
||
if idx := strings.Index(url, "://"); idx >= 0 {
|
||
protocol := url[:idx+3]
|
||
rest := url[idx+3:]
|
||
|
||
if atIdx := strings.Index(rest, "@"); atIdx >= 0 {
|
||
rest = rest[atIdx+1:]
|
||
}
|
||
|
||
url = protocol + rest
|
||
}
|
||
|
||
// 2. Убрать auth query параметры
|
||
authParams := []string{
|
||
"user=", "username=", "usr=",
|
||
"pwd=", "password=", "pass=",
|
||
}
|
||
|
||
for _, param := range authParams {
|
||
if idx := strings.Index(url, "?"+param); idx >= 0 {
|
||
// Найти конец параметра
|
||
endIdx := strings.Index(url[idx+1:], "&")
|
||
if endIdx >= 0 {
|
||
url = url[:idx+1] + url[idx+1+endIdx+1:]
|
||
} else {
|
||
url = url[:idx]
|
||
}
|
||
}
|
||
|
||
if idx := strings.Index(url, "&"+param); idx >= 0 {
|
||
endIdx := strings.Index(url[idx+1:], "&")
|
||
if endIdx >= 0 {
|
||
url = url[:idx] + url[idx+1+endIdx:]
|
||
} else {
|
||
url = url[:idx]
|
||
}
|
||
}
|
||
}
|
||
|
||
// 3. Убрать trailing ?
|
||
url = strings.TrimSuffix(url, "?")
|
||
|
||
return url
|
||
}
|