Optimize RTSP URL generation: eliminate duplicate streams

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
This commit is contained in:
eduard256
2025-11-09 18:47:37 +03:00
parent 387f252b9d
commit 19eddba1ee
5 changed files with 1233 additions and 9 deletions
+8 -6
View File
@@ -323,15 +323,17 @@ func (b *Builder) BuildURLsFromEntry(entry models.CameraEntry, ctx BuildContext)
}
case "rtsp", "rtsps":
// For RTSP: generate with and without credentials
// For RTSP: generate ONLY with credentials if provided, otherwise without
if ctx.Username != "" && ctx.Password != "" {
// Credentials provided - generate ONLY URL with auth
addURL(b.BuildURL(entry, ctx))
} else {
// No credentials - generate ONLY URL without auth
ctxNoAuth := ctx
ctxNoAuth.Username = ""
ctxNoAuth.Password = ""
addURL(b.BuildURL(entry, ctxNoAuth))
}
// Without credentials (for open cameras)
ctxNoAuth := ctx
ctxNoAuth.Username = ""
ctxNoAuth.Password = ""
addURL(b.BuildURL(entry, ctxNoAuth))
case "http", "https":
// For HTTP/HTTPS: ALWAYS generate 4 authentication variants
+5 -3
View File
@@ -63,7 +63,7 @@ func TestCurrentDeduplicationProblems(t *testing.T) {
description: "PROBLEM: Placeholder replacement + auth variants = duplicates",
},
{
name: "RTSP with/without credentials",
name: "RTSP with credentials - now FIXED",
entry: models.CameraEntry{
Type: "FFMPEG",
Protocol: "rtsp",
@@ -76,9 +76,9 @@ func TestCurrentDeduplicationProblems(t *testing.T) {
Password: "12345",
Port: 554,
},
expectedURLCount: 2, // С credentials и без
expectedURLCount: 1, // FIXED: только с credentials
realUniqueCount: 1, // Это один поток
description: "PROBLEM: RTSP with and without credentials are both generated",
description: "FIXED: RTSP with credentials generates ONLY auth URL",
},
{
name: "RTSP without credentials - only one URL",
@@ -123,6 +123,8 @@ func TestCurrentDeduplicationProblems(t *testing.T) {
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
@@ -0,0 +1,420 @@
package stream
import (
"testing"
"github.com/eduard256/Strix/internal/models"
)
// TestRealWorldDeduplication тестирует реальный сценарий:
// 5 одинаковых URL из 3 разных источников (ONVIF, Model patterns, Popular patterns)
func TestRealWorldDeduplication(t *testing.T) {
logger := &mockLogger{}
builder := NewBuilder([]string{}, logger)
ctx := BuildContext{
IP: "192.168.1.100",
Username: "admin",
Password: "12345",
Channel: 1,
Port: 554,
}
t.Log("\n========================================")
t.Log("REAL WORLD SCENARIO: Same stream from 3 sources")
t.Log("========================================\n")
// === SOURCE 1: ONVIF Discovery ===
t.Log("=== SOURCE 1: ONVIF Discovery ===")
onvifStreams := []models.DiscoveredStream{
{
URL: "rtsp://192.168.1.100:554/Streaming/Channels/101",
Type: "ONVIF",
Protocol: "rtsp",
Port: 554,
Working: true, // ONVIF streams are pre-verified
},
}
t.Logf("ONVIF discovered: %d URLs", len(onvifStreams))
for i, s := range onvifStreams {
t.Logf(" [ONVIF-%d] %s", i+1, s.URL)
}
// === SOURCE 2: Model-specific patterns (Hikvision) ===
t.Log("\n=== SOURCE 2: Model-specific patterns (Hikvision DS-2CD2086) ===")
modelEntry := models.CameraEntry{
Models: []string{"DS-2CD2086G2-I", "DS-2CD2042WD"},
Type: "FFMPEG",
Protocol: "rtsp",
Port: 554,
URL: "/Streaming/Channels/101",
}
modelURLs := builder.BuildURLsFromEntry(modelEntry, ctx)
t.Logf("Model patterns generated: %d URLs", len(modelURLs))
for i, url := range modelURLs {
t.Logf(" [MODEL-%d] %s", i+1, url)
}
// === SOURCE 3: Popular patterns ===
t.Log("\n=== SOURCE 3: Popular patterns (generic RTSP) ===")
popularEntry := models.CameraEntry{
Type: "FFMPEG",
Protocol: "rtsp",
Port: 554,
URL: "/Streaming/Channels/101",
}
popularURLs := builder.BuildURLsFromEntry(popularEntry, ctx)
t.Logf("Popular patterns generated: %d URLs", len(popularURLs))
for i, url := range popularURLs {
t.Logf(" [POPULAR-%d] %s", i+1, url)
}
// === CURRENT DEDUPLICATION (как в scanner.go:235-395) ===
t.Log("\n=== CURRENT DEDUPLICATION (string comparison) ===")
urlMap := make(map[string]bool)
var allStreams []models.DiscoveredStream
// Add ONVIF streams
for _, stream := range onvifStreams {
if !urlMap[stream.URL] {
allStreams = append(allStreams, stream)
urlMap[stream.URL] = true
t.Logf("✓ Added: %s (from ONVIF)", stream.URL)
} else {
t.Logf("✗ Skipped: %s (duplicate from ONVIF)", stream.URL)
}
}
// Add Model URLs
for _, url := range modelURLs {
if !urlMap[url] {
allStreams = append(allStreams, models.DiscoveredStream{
URL: url,
Type: modelEntry.Type,
Protocol: modelEntry.Protocol,
Port: modelEntry.Port,
})
urlMap[url] = true
t.Logf("✓ Added: %s (from Model)", url)
} else {
t.Logf("✗ Skipped: %s (duplicate from Model)", url)
}
}
// Add Popular URLs
for _, url := range popularURLs {
if !urlMap[url] {
allStreams = append(allStreams, models.DiscoveredStream{
URL: url,
Type: popularEntry.Type,
Protocol: popularEntry.Protocol,
Port: popularEntry.Port,
})
urlMap[url] = true
t.Logf("✓ Added: %s (from Popular)", url)
} else {
t.Logf("✗ Skipped: %s (duplicate from Popular)", url)
}
}
// === RESULTS ===
t.Log("\n========================================")
t.Log("DEDUPLICATION RESULTS")
t.Log("========================================")
totalGenerated := len(onvifStreams) + len(modelURLs) + len(popularURLs)
t.Logf("Total URLs generated: %d", totalGenerated)
t.Logf(" - From ONVIF: %d", len(onvifStreams))
t.Logf(" - From Model: %d", len(modelURLs))
t.Logf(" - From Popular: %d", len(popularURLs))
t.Logf("\nURLs after deduplication: %d", len(allStreams))
t.Logf("Duplicates removed: %d", totalGenerated-len(allStreams))
// List final URLs
t.Log("\nFinal URLs to test:")
for i, stream := range allStreams {
t.Logf(" [%d] %s (type: %s)", i+1, stream.URL, stream.Type)
}
// === CANONICAL ANALYSIS (показывает реальные дубликаты) ===
t.Log("\n========================================")
t.Log("CANONICAL ANALYSIS (semantic duplicates)")
t.Log("========================================")
canonicalMap := make(map[string][]string)
for _, stream := range allStreams {
canonical := normalizeURLForComparison(stream.URL)
canonicalMap[canonical] = append(canonicalMap[canonical], stream.URL)
}
realUnique := len(canonicalMap)
semanticDuplicates := len(allStreams) - realUnique
t.Logf("Real unique streams: %d", realUnique)
t.Logf("Semantic duplicates: %d", semanticDuplicates)
if semanticDuplicates > 0 {
t.Log("\n⚠️ PROBLEM: Multiple URLs point to the SAME stream:")
for canonical, variants := range canonicalMap {
if len(variants) > 1 {
t.Logf("\n Canonical: %s", canonical)
t.Logf(" Variants (%d):", len(variants))
for _, v := range variants {
t.Logf(" - %s", v)
}
t.Logf(" ⚠️ This stream will be tested %d times!", len(variants))
}
}
t.Logf("\n⚠️ WASTE: %d unnecessary tests", semanticDuplicates)
t.Logf("Time waste: ~%d seconds (assuming 2s per test)", semanticDuplicates*2)
t.Logf("Bandwidth waste: ~%d KB (assuming 100KB per test)", semanticDuplicates*100)
} else {
t.Log("\n✓ No semantic duplicates found")
}
// === ASSERTION ===
if semanticDuplicates > 0 {
t.Errorf("DEDUPLICATION FAILED: %d semantic duplicates not removed", semanticDuplicates)
}
}
// TestHTTPAuthVariantsDuplication проверяет дубликаты от HTTP auth вариантов
func TestHTTPAuthVariantsDuplication(t *testing.T) {
logger := &mockLogger{}
builder := NewBuilder([]string{}, logger)
ctx := BuildContext{
IP: "192.168.1.100",
Username: "admin",
Password: "12345",
Port: 80,
}
t.Log("\n========================================")
t.Log("HTTP AUTHENTICATION VARIANTS TEST")
t.Log("========================================\n")
// Один entry для HTTP
entry := models.CameraEntry{
Type: "JPEG",
Protocol: "http",
Port: 80,
URL: "snapshot.cgi",
}
t.Log("Entry: http://192.168.1.100/snapshot.cgi")
t.Log("\nBuilder generates auth variants:")
urls := builder.BuildURLsFromEntry(entry, ctx)
for i, url := range urls {
t.Logf(" [%d] %s", i+1, url)
}
t.Logf("\nTotal URLs generated: %d", len(urls))
// Canonical analysis
canonicalMap := make(map[string][]string)
for _, url := range urls {
canonical := normalizeURLForComparison(url)
canonicalMap[canonical] = append(canonicalMap[canonical], url)
}
t.Logf("Real unique endpoints: %d", len(canonicalMap))
semanticDuplicates := len(urls) - len(canonicalMap)
t.Logf("Semantic duplicates: %d", semanticDuplicates)
if semanticDuplicates > 0 {
t.Log("\n⚠️ PROBLEM: Multiple auth variants for the SAME endpoint:")
for canonical, variants := range canonicalMap {
if len(variants) > 1 {
t.Logf("\n Endpoint: %s", canonical)
t.Logf(" Auth variants (%d):", len(variants))
for j, v := range variants {
t.Logf(" [%d] %s", j+1, v)
}
}
}
t.Logf("\n⚠️ All %d variants will be tested, but only 1 will likely work", len(urls))
t.Logf("Expected success rate: ~25%% (1 out of 4)")
t.Logf("Expected failures: ~%d", len(urls)-1)
}
// Note: это НЕ ошибка - это feature для повышения шансов найти рабочий вариант auth
t.Log("\nNOTE: This is intentional - trying multiple auth methods increases success rate")
t.Log("But it does mean testing the same stream multiple times with different credentials")
}
// TestFiveIdenticalURLsFromThreeSources - главный тест: ровно 5 одинаковых URL
func TestFiveIdenticalURLsFromThreeSources(t *testing.T) {
logger := &mockLogger{}
builder := NewBuilder([]string{}, logger)
ctx := BuildContext{
IP: "192.168.1.100",
Username: "admin",
Password: "password123",
Port: 554,
}
t.Log("\n========================================")
t.Log("TEST: 5 IDENTICAL URLs from 3 SOURCES")
t.Log("========================================\n")
// SOURCE 1: ONVIF - returns 1 URL without auth
onvifURL := "rtsp://192.168.1.100:554/live/ch0"
t.Log("SOURCE 1 - ONVIF Discovery:")
t.Logf(" Returns: %s", onvifURL)
// SOURCE 2: Model patterns - generates 2 URLs (with/without auth)
modelEntry := models.CameraEntry{
Type: "FFMPEG",
Protocol: "rtsp",
Port: 554,
URL: "/live/ch0",
}
modelURLs := builder.BuildURLsFromEntry(modelEntry, ctx)
t.Log("\nSOURCE 2 - Model Patterns (Hikvision):")
t.Logf(" Generates: %d URLs", len(modelURLs))
for i, url := range modelURLs {
t.Logf(" [%d] %s", i+1, url)
}
// SOURCE 3: Popular patterns - generates 2 URLs (with/without auth)
popularEntry := models.CameraEntry{
Type: "FFMPEG",
Protocol: "rtsp",
Port: 554,
URL: "/live/ch0",
}
popularURLs := builder.BuildURLsFromEntry(popularEntry, ctx)
t.Log("\nSOURCE 3 - Popular Patterns:")
t.Logf(" Generates: %d URLs", len(popularURLs))
for i, url := range popularURLs {
t.Logf(" [%d] %s", i+1, url)
}
// Simulate current deduplication
urlMap := make(map[string]bool)
var combined []string
// Add ONVIF
if !urlMap[onvifURL] {
combined = append(combined, onvifURL)
urlMap[onvifURL] = true
}
// Add Model
for _, url := range modelURLs {
if !urlMap[url] {
combined = append(combined, url)
urlMap[url] = true
}
}
// Add Popular
for _, url := range popularURLs {
if !urlMap[url] {
combined = append(combined, url)
urlMap[url] = true
}
}
t.Log("\n========================================")
t.Log("RESULTS")
t.Log("========================================")
totalGenerated := 1 + len(modelURLs) + len(popularURLs)
t.Logf("Total URLs from all sources: %d", totalGenerated)
t.Logf(" ONVIF: 1")
t.Logf(" Model: %d", len(modelURLs))
t.Logf(" Popular: %d", len(popularURLs))
t.Logf("\nAfter string-based deduplication: %d URLs", len(combined))
t.Logf("Removed by string comparison: %d", totalGenerated-len(combined))
t.Log("\nFinal URLs to test:")
for i, url := range combined {
t.Logf(" [%d] %s", i+1, url)
}
// Canonical analysis
canonicalMap := make(map[string][]string)
for _, url := range combined {
canonical := normalizeURLForComparison(url)
canonicalMap[canonical] = append(canonicalMap[canonical], url)
}
realUnique := len(canonicalMap)
semanticDuplicates := len(combined) - realUnique
t.Log("\n========================================")
t.Log("SEMANTIC ANALYSIS")
t.Log("========================================")
t.Logf("Real unique streams: %d", realUnique)
t.Logf("Semantic duplicates: %d", semanticDuplicates)
if semanticDuplicates > 0 {
t.Log("\n⚠️ CRITICAL ISSUE:")
t.Logf("The same stream will be tested %d times!", len(combined))
t.Log("\nBreakdown:")
for canonical, variants := range canonicalMap {
t.Logf("\n Stream: %s", canonical)
t.Logf(" Will be tested %d times as:", len(variants))
for i, v := range variants {
t.Logf(" [%d] %s", i+1, v)
}
}
t.Log("\n⚠️ IMPACT:")
t.Logf(" - Wasted tests: %d", semanticDuplicates)
t.Logf(" - Wasted time: ~%d seconds", semanticDuplicates*2)
t.Logf(" - Efficiency: %.1f%% (should be 100%%)",
float64(realUnique)/float64(len(combined))*100)
t.Errorf("\nDEDUPLICATION FAILED: %d duplicates not detected", semanticDuplicates)
} else {
t.Log("\n✓ SUCCESS: All duplicates properly detected")
}
}
// normalizeURLForComparison убирает различия в auth для сравнения
func normalizeURLForComparison(rawURL string) string {
// Простая нормализация: убираем user:pass@ из URL
url := rawURL
// Найти protocol://
protocolEnd := 0
for i := 0; i < len(url)-3; i++ {
if url[i:i+3] == "://" {
protocolEnd = i + 3
break
}
}
if protocolEnd == 0 {
return url
}
protocol := url[:protocolEnd]
rest := url[protocolEnd:]
// Убрать user:pass@
atIndex := -1
for i := 0; i < len(rest); i++ {
if rest[i] == '@' {
atIndex = i
break
}
if rest[i] == '/' {
break
}
}
if atIndex >= 0 {
rest = rest[atIndex+1:]
}
return protocol + rest
}
@@ -0,0 +1,351 @@
package stream
import (
"strings"
"testing"
"github.com/eduard256/Strix/internal/models"
)
// TestProtocolAuthBehaviorComparison проверяет разницу в генерации auth вариантов
// между RTSP и HTTP протоколами
func TestProtocolAuthBehaviorComparison(t *testing.T) {
logger := &mockLogger{}
builder := NewBuilder([]string{}, logger)
ctx := BuildContext{
IP: "192.168.1.100",
Username: "admin",
Password: "12345",
Port: 0, // Will use default for protocol
}
t.Log("\n" + strings.Repeat("=", 80))
t.Log("PROTOCOL AUTH BEHAVIOR COMPARISON")
t.Log(strings.Repeat("=", 80))
// === RTSP ===
t.Log("\n### RTSP Protocol ###")
rtspEntry := models.CameraEntry{
Type: "FFMPEG",
Protocol: "rtsp",
Port: 554,
URL: "/live/ch0",
}
rtspURLs := builder.BuildURLsFromEntry(rtspEntry, ctx)
t.Logf("\nRTSP with credentials (user=%s, pass=%s):", "admin", "***")
t.Logf("Generated: %d URL(s)", len(rtspURLs))
for i, url := range rtspURLs {
t.Logf(" [%d] %s", i+1, url)
}
// Check RTSP behavior
if len(rtspURLs) != 1 {
t.Errorf("❌ RTSP: Expected 1 URL, got %d", len(rtspURLs))
}
hasRTSPAuth := false
hasRTSPNoAuth := false
for _, url := range rtspURLs {
if strings.Contains(url, "@") {
hasRTSPAuth = true
} else {
hasRTSPNoAuth = true
}
}
if !hasRTSPAuth {
t.Error("❌ RTSP: Should have URL WITH auth")
}
if hasRTSPNoAuth {
t.Error("❌ RTSP: Should NOT have URL without auth when credentials provided")
}
if len(rtspURLs) == 1 && hasRTSPAuth && !hasRTSPNoAuth {
t.Log("✅ RTSP: Correctly generates ONLY auth URL")
}
// === HTTP ===
t.Log("\n### HTTP Protocol ###")
httpEntry := models.CameraEntry{
Type: "JPEG",
Protocol: "http",
Port: 80,
URL: "snapshot.cgi",
}
httpURLs := builder.BuildURLsFromEntry(httpEntry, ctx)
t.Logf("\nHTTP with credentials (user=%s, pass=%s):", "admin", "***")
t.Logf("Generated: %d URL(s)", len(httpURLs))
for i, url := range httpURLs {
t.Logf(" [%d] %s", i+1, url)
}
// Check HTTP behavior
if len(httpURLs) != 4 {
t.Errorf("❌ HTTP: Expected 4 URLs, got %d", len(httpURLs))
}
// Analyze HTTP URLs
type authVariant struct {
name string
found bool
url string
}
variants := []authVariant{
{name: "No auth", found: false},
{name: "Basic auth only", found: false},
{name: "Query params only", found: false},
{name: "Basic auth + Query params", found: false},
}
for _, url := range httpURLs {
hasBasicAuth := strings.Contains(url, "@")
hasQueryParams := strings.Contains(url, "?")
if !hasBasicAuth && !hasQueryParams {
variants[0].found = true
variants[0].url = url
} else if hasBasicAuth && !hasQueryParams {
variants[1].found = true
variants[1].url = url
} else if !hasBasicAuth && hasQueryParams {
variants[2].found = true
variants[2].url = url
} else if hasBasicAuth && hasQueryParams {
variants[3].found = true
variants[3].url = url
}
}
t.Log("\nHTTP Auth variants breakdown:")
allFound := true
for i, v := range variants {
if v.found {
t.Logf(" ✅ [%d] %s: %s", i+1, v.name, v.url)
} else {
t.Errorf(" ❌ [%d] %s: MISSING", i+1, v.name)
allFound = false
}
}
if allFound {
t.Log("\n✅ HTTP: Correctly generates ALL 4 auth variants")
} else {
t.Error("\n❌ HTTP: Missing some auth variants")
}
// === COMPARISON SUMMARY ===
t.Log("\n" + strings.Repeat("=", 80))
t.Log("SUMMARY")
t.Log(strings.Repeat("=", 80))
t.Log("\nRTSP behavior:")
t.Log(" • With credentials → 1 URL (WITH auth only)")
t.Log(" • Without credentials → 1 URL (NO auth only)")
t.Log(" • Rationale: RTSP auth is binary (works or doesn't)")
t.Log("")
t.Log("HTTP behavior:")
t.Log(" • With credentials → 4 URLs:")
t.Log(" 1. No auth (try public access)")
t.Log(" 2. Basic auth only (user:pass@host)")
t.Log(" 3. Query params only (?user=X&pwd=Y)")
t.Log(" 4. Both methods combined")
t.Log(" • Rationale: Different cameras support different auth methods")
t.Log(strings.Repeat("=", 80))
}
// TestRTSPNoAuthWhenNoCredentials проверяет что RTSP без credentials НЕ генерирует auth URL
func TestRTSPNoAuthWhenNoCredentials(t *testing.T) {
logger := &mockLogger{}
builder := NewBuilder([]string{}, logger)
rtspEntry := models.CameraEntry{
Type: "FFMPEG",
Protocol: "rtsp",
Port: 554,
URL: "/live/main",
}
// Without credentials
ctxNoAuth := BuildContext{
IP: "192.168.1.100",
Username: "",
Password: "",
Port: 554,
}
urls := builder.BuildURLsFromEntry(rtspEntry, ctxNoAuth)
t.Log("\n=== RTSP WITHOUT credentials ===")
t.Logf("Generated: %d URL(s)", len(urls))
for i, url := range urls {
t.Logf(" [%d] %s", i+1, url)
}
if len(urls) != 1 {
t.Errorf("Expected 1 URL, got %d", len(urls))
}
if len(urls) > 0 {
if strings.Contains(urls[0], "@") {
t.Error("❌ Should NOT have auth when no credentials provided")
} else {
t.Log("✅ Correctly generates URL without auth")
}
}
}
// TestHTTPNoAuthWhenNoCredentials проверяет что HTTP без credentials генерирует ТОЛЬКО 1 URL
func TestHTTPNoAuthWhenNoCredentials(t *testing.T) {
logger := &mockLogger{}
builder := NewBuilder([]string{}, logger)
httpEntry := models.CameraEntry{
Type: "JPEG",
Protocol: "http",
Port: 80,
URL: "snapshot.jpg",
}
// Without credentials
ctxNoAuth := BuildContext{
IP: "192.168.1.100",
Username: "",
Password: "",
Port: 80,
}
urls := builder.BuildURLsFromEntry(httpEntry, ctxNoAuth)
t.Log("\n=== HTTP WITHOUT credentials ===")
t.Logf("Generated: %d URL(s)", len(urls))
for i, url := range urls {
t.Logf(" [%d] %s", i+1, url)
}
if len(urls) != 1 {
t.Errorf("Expected 1 URL, got %d", len(urls))
}
if len(urls) > 0 {
if strings.Contains(urls[0], "@") || strings.Contains(urls[0], "?") {
t.Error("❌ Should NOT have auth when no credentials provided")
} else {
t.Log("✅ Correctly generates URL without auth")
}
}
}
// TestCompleteProtocolMatrix проверяет полную матрицу протоколов и credentials
func TestCompleteProtocolMatrix(t *testing.T) {
logger := &mockLogger{}
builder := NewBuilder([]string{}, logger)
type testCase struct {
protocol string
port int
url string
withCreds bool
expectedURLs int
description string
}
tests := []testCase{
// RTSP
{
protocol: "rtsp",
port: 554,
url: "/live/ch0",
withCreds: true,
expectedURLs: 1,
description: "RTSP with credentials",
},
{
protocol: "rtsp",
port: 554,
url: "/live/ch0",
withCreds: false,
expectedURLs: 1,
description: "RTSP without credentials",
},
// HTTP
{
protocol: "http",
port: 80,
url: "snapshot.cgi",
withCreds: true,
expectedURLs: 4,
description: "HTTP with credentials",
},
{
protocol: "http",
port: 80,
url: "snapshot.cgi",
withCreds: false,
expectedURLs: 1,
description: "HTTP without credentials",
},
// HTTPS
{
protocol: "https",
port: 443,
url: "snapshot.cgi",
withCreds: true,
expectedURLs: 4,
description: "HTTPS with credentials",
},
{
protocol: "https",
port: 443,
url: "snapshot.cgi",
withCreds: false,
expectedURLs: 1,
description: "HTTPS without credentials",
},
}
t.Log("\n" + strings.Repeat("=", 80))
t.Log("COMPLETE PROTOCOL MATRIX")
t.Log(strings.Repeat("=", 80))
for _, tc := range tests {
t.Run(tc.description, func(t *testing.T) {
entry := models.CameraEntry{
Type: "FFMPEG",
Protocol: tc.protocol,
Port: tc.port,
URL: tc.url,
}
ctx := BuildContext{
IP: "192.168.1.100",
Port: tc.port,
}
if tc.withCreds {
ctx.Username = "admin"
ctx.Password = "12345"
}
urls := builder.BuildURLsFromEntry(entry, ctx)
t.Logf("Protocol: %s, Creds: %v → Generated: %d URL(s)",
tc.protocol, tc.withCreds, len(urls))
if len(urls) != tc.expectedURLs {
t.Errorf("❌ Expected %d URLs, got %d", tc.expectedURLs, len(urls))
for i, url := range urls {
t.Logf(" [%d] %s", i+1, url)
}
} else {
t.Logf("✅ Correct: %d URL(s)", len(urls))
}
})
}
t.Log(strings.Repeat("=", 80))
}
@@ -0,0 +1,449 @@
package stream
import (
"testing"
"github.com/eduard256/Strix/internal/models"
)
// TestRTSPAuthLogic проверяет логику генерации RTSP URL с авторизацией
func TestRTSPAuthLogic(t *testing.T) {
logger := &mockLogger{}
builder := NewBuilder([]string{}, logger)
entry := models.CameraEntry{
Type: "FFMPEG",
Protocol: "rtsp",
Port: 554,
URL: "/live/ch0",
}
tests := []struct {
name string
ctx BuildContext
expectedURLCount int
shouldHaveNoAuth bool
shouldHaveAuth bool
description string
}{
{
name: "RTSP with credentials - should generate ONLY with auth",
ctx: BuildContext{
IP: "192.168.1.100",
Username: "admin",
Password: "12345",
Port: 554,
},
expectedURLCount: 1,
shouldHaveNoAuth: false,
shouldHaveAuth: true,
description: "When credentials provided, generate ONLY URL with auth",
},
{
name: "RTSP without credentials - should generate ONLY without auth",
ctx: BuildContext{
IP: "192.168.1.100",
Username: "",
Password: "",
Port: 554,
},
expectedURLCount: 1,
shouldHaveNoAuth: true,
shouldHaveAuth: false,
description: "When NO credentials provided, generate ONLY URL without auth",
},
{
name: "RTSP with only username (no password) - should generate without auth",
ctx: BuildContext{
IP: "192.168.1.100",
Username: "admin",
Password: "",
Port: 554,
},
expectedURLCount: 1,
shouldHaveNoAuth: true,
shouldHaveAuth: false,
description: "Username without password = no credentials",
},
{
name: "RTSP with only password (no username) - should generate without auth",
ctx: BuildContext{
IP: "192.168.1.100",
Username: "",
Password: "12345",
Port: 554,
},
expectedURLCount: 1,
shouldHaveNoAuth: true,
shouldHaveAuth: false,
description: "Password without username = no credentials",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
urls := builder.BuildURLsFromEntry(entry, tt.ctx)
t.Logf("\n=== %s ===", tt.description)
t.Logf("Context: IP=%s, User=%s, Pass=%s",
tt.ctx.IP,
maskString(tt.ctx.Username),
maskString(tt.ctx.Password))
t.Logf("Generated URLs: %d", len(urls))
for i, url := range urls {
t.Logf(" [%d] %s", i+1, url)
}
// Check count
if len(urls) != tt.expectedURLCount {
t.Errorf("FAILED: Expected %d URLs, got %d", tt.expectedURLCount, len(urls))
}
// Check for auth presence
hasNoAuth := false
hasAuth := false
for _, url := range urls {
if containsAuth(url) {
hasAuth = true
} else {
hasNoAuth = true
}
}
if tt.shouldHaveNoAuth && !hasNoAuth {
t.Errorf("FAILED: Expected URL without auth, but none found")
}
if !tt.shouldHaveNoAuth && hasNoAuth {
t.Errorf("FAILED: Expected NO URL without auth, but found one")
}
if tt.shouldHaveAuth && !hasAuth {
t.Errorf("FAILED: Expected URL with auth, but none found")
}
if !tt.shouldHaveAuth && hasAuth {
t.Errorf("FAILED: Expected NO URL with auth, but found one")
}
t.Logf("✓ Test passed")
})
}
}
// TestHTTPAuthLogic проверяет что HTTP НЕ изменился (все 4 варианта)
func TestHTTPAuthLogic(t *testing.T) {
logger := &mockLogger{}
builder := NewBuilder([]string{}, logger)
entry := models.CameraEntry{
Type: "JPEG",
Protocol: "http",
Port: 80,
URL: "snapshot.cgi",
}
t.Log("\n=== HTTP should generate ALL 4 auth variants (unchanged behavior) ===")
ctx := BuildContext{
IP: "192.168.1.100",
Username: "admin",
Password: "12345",
Port: 80,
}
urls := builder.BuildURLsFromEntry(entry, ctx)
t.Logf("Generated URLs: %d", len(urls))
for i, url := range urls {
t.Logf(" [%d] %s", i+1, url)
}
expectedCount := 4
if len(urls) != expectedCount {
t.Errorf("FAILED: Expected %d URLs for HTTP, got %d", expectedCount, len(urls))
t.Errorf("HTTP auth variant generation should NOT be changed!")
} else {
t.Log("✓ HTTP still generates 4 auth variants (correct)")
}
// Verify we have different auth methods
hasNoAuth := false
hasBasicAuth := false
hasQueryAuth := false
hasBothAuth := false
for _, url := range urls {
hasAuth := containsAuth(url)
hasQuery := containsString(url, "?")
if !hasAuth && !hasQuery {
hasNoAuth = true
} else if hasAuth && !hasQuery {
hasBasicAuth = true
} else if !hasAuth && hasQuery {
hasQueryAuth = true
} else if hasAuth && hasQuery {
hasBothAuth = true
}
}
if !hasNoAuth || !hasBasicAuth || !hasQueryAuth || !hasBothAuth {
t.Error("FAILED: HTTP should have all 4 auth variants:")
t.Logf(" No auth: %v", hasNoAuth)
t.Logf(" Basic auth: %v", hasBasicAuth)
t.Logf(" Query auth: %v", hasQueryAuth)
t.Logf(" Both: %v", hasBothAuth)
} else {
t.Log("✓ All 4 HTTP auth variants present (correct)")
}
}
// TestHTTPSAuthLogic проверяет что HTTPS работает как HTTP
func TestHTTPSAuthLogic(t *testing.T) {
logger := &mockLogger{}
builder := NewBuilder([]string{}, logger)
entry := models.CameraEntry{
Type: "JPEG",
Protocol: "https",
Port: 443,
URL: "snapshot.cgi",
}
t.Log("\n=== HTTPS should generate ALL 4 auth variants (same as HTTP) ===")
ctx := BuildContext{
IP: "192.168.1.100",
Username: "admin",
Password: "12345",
Port: 443,
}
urls := builder.BuildURLsFromEntry(entry, ctx)
t.Logf("Generated URLs: %d", len(urls))
for i, url := range urls {
t.Logf(" [%d] %s", i+1, url)
}
expectedCount := 4
if len(urls) != expectedCount {
t.Errorf("FAILED: Expected %d URLs for HTTPS, got %d", expectedCount, len(urls))
} else {
t.Log("✓ HTTPS generates 4 auth variants (correct)")
}
}
// TestBUBBLEProtocolUnchanged проверяет что BUBBLE протокол не изменился
func TestBUBBLEProtocolUnchanged(t *testing.T) {
logger := &mockLogger{}
builder := NewBuilder([]string{}, logger)
entry := models.CameraEntry{
Type: "BUBBLE",
Protocol: "bubble",
Port: 34567,
URL: "/{channel}?stream=0",
}
t.Log("\n=== BUBBLE protocol should remain unchanged ===")
ctx := BuildContext{
IP: "192.168.1.100",
Username: "admin",
Password: "12345",
Port: 34567,
Channel: 1,
}
urls := builder.BuildURLsFromEntry(entry, ctx)
t.Logf("Generated URLs: %d", len(urls))
for i, url := range urls {
t.Logf(" [%d] %s", i+1, url)
}
if len(urls) < 1 {
t.Error("FAILED: BUBBLE should generate at least 1 URL")
} else {
t.Log("✓ BUBBLE protocol works")
}
}
// TestRTSPDeduplicationAcrossSources проверяет дедупликацию между источниками
func TestRTSPDeduplicationAcrossSources(t *testing.T) {
logger := &mockLogger{}
builder := NewBuilder([]string{}, logger)
ctx := BuildContext{
IP: "192.168.1.100",
Username: "admin",
Password: "12345",
Port: 554,
}
entry := models.CameraEntry{
Type: "FFMPEG",
Protocol: "rtsp",
Port: 554,
URL: "/live/ch0",
}
t.Log("\n=== RTSP Deduplication: Each source generates ONLY auth URL ===")
// Source 1: Model patterns
modelURLs := builder.BuildURLsFromEntry(entry, ctx)
t.Logf("Model patterns: %d URLs", len(modelURLs))
for i, url := range modelURLs {
t.Logf(" [%d] %s", i+1, url)
}
// Source 2: Popular patterns
popularURLs := builder.BuildURLsFromEntry(entry, ctx)
t.Logf("Popular patterns: %d URLs", len(popularURLs))
for i, url := range popularURLs {
t.Logf(" [%d] %s", i+1, url)
}
// Source 3: ONVIF (manual simulation - without auth)
onvifURL := "rtsp://192.168.1.100:554/live/ch0"
t.Logf("ONVIF: 1 URL")
t.Logf(" [1] %s", onvifURL)
// Current deduplication
urlMap := make(map[string]bool)
var combined []string
for _, url := range modelURLs {
if !urlMap[url] {
combined = append(combined, url)
urlMap[url] = true
}
}
for _, url := range popularURLs {
if !urlMap[url] {
combined = append(combined, url)
urlMap[url] = true
}
}
if !urlMap[onvifURL] {
combined = append(combined, onvifURL)
urlMap[onvifURL] = true
}
t.Logf("\nAfter deduplication: %d URLs", len(combined))
for i, url := range combined {
t.Logf(" [%d] %s", i+1, url)
}
// Verify: should have exactly 2 URLs
// 1. From Model/Popular (with auth): rtsp://admin:12345@192.168.1.100/live/ch0
// 2. From ONVIF (without auth, with port): rtsp://192.168.1.100:554/live/ch0
expectedCount := 2
if len(combined) != expectedCount {
t.Errorf("FAILED: Expected %d unique URLs, got %d", expectedCount, len(combined))
t.Log("Expected:")
t.Log(" 1. rtsp://admin:12345@192.168.1.100/live/ch0 (from Model/Popular)")
t.Log(" 2. rtsp://192.168.1.100:554/live/ch0 (from ONVIF)")
} else {
t.Log("✓ Deduplication works correctly")
t.Log(" Model/Popular URLs are identical → deduplicated to 1")
t.Log(" ONVIF URL is different (has :554 port) → kept as separate")
t.Log(" Total: 2 unique URLs (correct!)")
}
}
// TestRTSPWithoutCredentialsSingleURL проверяет что без credentials генерируется 1 URL
func TestRTSPWithoutCredentialsSingleURL(t *testing.T) {
logger := &mockLogger{}
builder := NewBuilder([]string{}, logger)
entry := models.CameraEntry{
Type: "FFMPEG",
Protocol: "rtsp",
Port: 554,
URL: "/live/main",
}
ctx := BuildContext{
IP: "192.168.1.100",
Username: "",
Password: "",
Port: 554,
}
t.Log("\n=== RTSP without credentials should generate SINGLE URL ===")
urls := builder.BuildURLsFromEntry(entry, ctx)
t.Logf("Generated URLs: %d", len(urls))
for i, url := range urls {
t.Logf(" [%d] %s", i+1, url)
}
if len(urls) != 1 {
t.Errorf("FAILED: Expected 1 URL without credentials, got %d", len(urls))
}
if len(urls) > 0 && containsAuth(urls[0]) {
t.Error("FAILED: URL should NOT contain auth when no credentials provided")
}
t.Log("✓ Single URL without auth generated (correct)")
}
// Helper functions
func containsAuth(url string) bool {
// Check for user:pass@ pattern
for i := 0; i < len(url)-3; i++ {
if url[i:i+3] == "://" {
// Found protocol, check for @
for j := i + 3; j < len(url); j++ {
if url[j] == '@' {
return true
}
if url[j] == '/' {
break
}
}
break
}
}
return false
}
func containsString(s, substr string) bool {
return len(s) >= len(substr) && findSubstring(s, substr)
}
func findSubstring(s, substr string) bool {
if len(substr) == 0 {
return true
}
if len(s) < len(substr) {
return false
}
for i := 0; i <= len(s)-len(substr); i++ {
match := true
for j := 0; j < len(substr); j++ {
if s[i+j] != substr[j] {
match = false
break
}
}
if match {
return true
}
}
return false
}
func maskString(s string) string {
if s == "" {
return "(empty)"
}
return "***"
}