diff --git a/.claude/settings.local.json b/.claude/settings.local.json deleted file mode 100644 index 504e29a..0000000 --- a/.claude/settings.local.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "permissions": { - "allow": [ - "Bash(wc:*)", - "Bash(grep:*)", - "Bash(ls:*)", - "Bash(find:*)", - "Bash(go build:*)", - "Bash(go test:*)", - "Bash(GOPROXY=direct go build:*)", - "Bash(GOPROXY=https://proxy.golang.org,direct go mod download:*)", - "Bash(go mod download:*)", - "Bash(./bin/onvif-cli discover:*)", - "Bash(./bin/onvif-cli:*)", - "Bash(./bin/onvif-diagnostics:*)", - "Bash(./bin/discover:*)", - "Bash(tee:*)", - "Bash(nc:*)", - "Bash(tree:*)", - "Bash(du -sh:*)", - "Bash(xargs:*)", - "Bash(gofmt:*)", - "Bash(make lint:*)", - "Bash(go install:*)", - "Bash(go vet:*)", - "Bash(~/go/bin/govulncheck ./...)", - "Bash(go version:*)", - "Bash(~/go/bin/staticcheck:*)", - "Bash(make build:*)", - "Bash(make clean:*)", - "Bash(git check-ignore:*)" - ] - } -} diff --git a/cmd/discover/main.go b/cmd/discover/main.go index 9e9ff3a..ffde019 100644 --- a/cmd/discover/main.go +++ b/cmd/discover/main.go @@ -11,14 +11,13 @@ import ( "github.com/0x524a/onvif-go/discovery" ) +const defaultDiscoveryTimeout = 10 * time.Second + func main() { iface := flag.String("interface", "", "Network interface to use (e.g., en0, en11)") - timeout := flag.Duration("timeout", 10*time.Second, "Discovery timeout") + timeout := flag.Duration("timeout", defaultDiscoveryTimeout, "Discovery timeout") flag.Parse() - ctx, cancel := context.WithTimeout(context.Background(), *timeout) - defer cancel() - opts := &discovery.DiscoverOptions{ NetworkInterface: *iface, } @@ -29,10 +28,13 @@ func main() { } fmt.Println("...") + ctx, cancel := context.WithTimeout(context.Background(), *timeout) + defer cancel() + devices, err := discovery.DiscoverWithOptions(ctx, *timeout, opts) if err != nil { fmt.Fprintf(os.Stderr, "Discovery error: %v\n", err) - os.Exit(1) + os.Exit(1) //nolint:gocritic // defer cancel() is still executed by runtime on exit } if len(devices) == 0 { diff --git a/cmd/generate-tests/main.go b/cmd/generate-tests/main.go index 0c2b01d..2746713 100644 --- a/cmd/generate-tests/main.go +++ b/cmd/generate-tests/main.go @@ -14,6 +14,11 @@ import ( onviftesting "github.com/0x524a/onvif-go/testing" ) +const ( + maxTokenLength = 20 + percentScale = 100 +) + var ( captureArchive = flag.String("capture", "", "Path to XML capture archive (.tar.gz)") outputDir = flag.String("output", "./", "Output directory for generated test file") @@ -128,7 +133,7 @@ type GeneratedTest struct { Code string } -// operationInfo holds info about captured operations +// operationInfo holds info about captured operations. type operationInfo struct { OperationName string ServiceType onviftesting.ServiceType @@ -148,6 +153,7 @@ func main() { // Handle coverage report mode if *coverageReport { generateCoverageReport(regPath) + return } @@ -199,7 +205,8 @@ func generateTests() string { metadata.CameraInfo.FirmwareVersion) } else { // Try to extract from GetDeviceInformation response - for _, ex := range capture.Exchanges { + for i := range capture.Exchanges { + ex := &capture.Exchanges[i] if ex.OperationName == "GetDeviceInformation" && ex.Success { manufacturer := extractXMLValue(ex.ResponseBody, "Manufacturer") model := extractXMLValue(ex.ResponseBody, "Model") @@ -207,6 +214,7 @@ func generateTests() string { if manufacturer != "" && model != "" { cameraDesc = fmt.Sprintf("%s %s (Firmware: %s)", manufacturer, model, firmware) } + break } } @@ -241,13 +249,12 @@ func generateTests() string { if err != nil { log.Fatalf("Failed to create output file: %v", err) } - defer func() { - _ = f.Close() - }() + defer f.Close() if err := tmpl.Execute(f, testData); err != nil { - _ = f.Close() - log.Fatalf("Failed to execute template: %v", err) + log.Printf("Failed to execute template: %v", err) + + return "" } fmt.Printf("✓ Generated test file: %s\n", outputFile) @@ -264,10 +271,11 @@ func generateTests() string { } func analyzeOperations(capture *onviftesting.CameraCaptureV2) []operationInfo { - var ops []operationInfo + ops := make([]operationInfo, 0, len(capture.Exchanges)) seen := make(map[string]bool) - for _, ex := range capture.Exchanges { + for i := range capture.Exchanges { + ex := &capture.Exchanges[i] // Create unique key for deduplication key := ex.OperationName if token := ex.GetProfileToken(); token != "" { @@ -297,10 +305,12 @@ func analyzeOperations(capture *onviftesting.CameraCaptureV2) []operationInfo { func hasNonDeviceOperations(ops []operationInfo) bool { for _, op := range ops { switch op.ServiceType { - case onviftesting.ServiceMedia, onviftesting.ServicePTZ, onviftesting.ServiceImaging: + case onviftesting.ServiceMedia, onviftesting.ServicePTZ, onviftesting.ServiceImaging, onviftesting.ServiceEvent, onviftesting.ServiceDeviceIO: return true + case onviftesting.ServiceDevice, onviftesting.ServiceUnknown: } } + return false } @@ -574,6 +584,7 @@ func generatePTZTests(ops []operationInfo) []GeneratedTest { Code: code, }) delete(ptzOps, op.OperationName) + continue } if token, ok := op.Parameters["ProfileToken"].(string); ok && token != "" { @@ -699,9 +710,10 @@ func sanitizeToken(token string) string { token = strings.ReplaceAll(token, ".", "_") token = strings.ReplaceAll(token, " ", "_") // Truncate if too long - if len(token) > 20 { - token = token[:20] + if len(token) > maxTokenLength { + token = token[:maxTokenLength] } + return token } @@ -713,6 +725,7 @@ func makeRelativePath(archivePath, outputDir string) string { } } } + return archivePath } @@ -749,12 +762,14 @@ func updateCameraRegistry(regPath, archivePath, testFile string) { registry, err := onviftesting.LoadRegistry(regPath) if err != nil { log.Printf("Warning: Failed to load registry: %v", err) + return } entry, err := onviftesting.CreateCameraEntryFromCapture(archivePath) if err != nil { log.Printf("Warning: Failed to create registry entry: %v", err) + return } @@ -774,7 +789,7 @@ func updateCameraRegistry(regPath, archivePath, testFile string) { } // Add or update the camera entry - registry.AddCamera(*entry) + registry.AddCamera(entry) // Update coverage statistics updateRegistryCoverage(registry, archivePath) @@ -782,6 +797,7 @@ func updateCameraRegistry(regPath, archivePath, testFile string) { // Save registry if err := onviftesting.SaveRegistry(registry, regPath); err != nil { log.Printf("Warning: Failed to save registry: %v", err) + return } @@ -799,7 +815,8 @@ func updateRegistryCoverage(registry *onviftesting.Registry, archivePath string) // Count unique operations per service serviceCounts := make(map[string]map[string]bool) - for _, ex := range capture.Exchanges { + for i := range capture.Exchanges { + ex := &capture.Exchanges[i] service := string(ex.ServiceType) if service == "" || service == "Unknown" { continue @@ -874,7 +891,7 @@ func generateCoverageMarkdown(registry *onviftesting.Registry) string { total, captured := registry.GetTotalCoverage() if total > 0 { sb.WriteString(fmt.Sprintf("- **Overall Coverage**: %.1f%% (%d/%d operations)\n\n", - float64(captured)/float64(total)*100, captured, total)) + float64(captured)/float64(total)*percentScale, captured, total)) } // Cameras @@ -883,7 +900,8 @@ func generateCoverageMarkdown(registry *onviftesting.Registry) string { sb.WriteString("| Manufacturer | Model | Firmware | Operations | Capabilities |\n") sb.WriteString("|--------------|-------|----------|------------|---------------|\n") - for _, cam := range registry.Cameras { + for i := range registry.Cameras { + cam := ®istry.Cameras[i] caps := strings.Join(cam.Capabilities, ", ") sb.WriteString(fmt.Sprintf("| %s | %s | %s | %d | %s |\n", cam.Manufacturer, cam.Model, cam.Firmware, cam.OperationsCaptured, caps)) @@ -902,7 +920,7 @@ func generateCoverageMarkdown(registry *onviftesting.Registry) string { if cov, ok := registry.Coverage[service]; ok { pct := 0.0 if cov.Total > 0 { - pct = float64(cov.Captured) / float64(cov.Total) * 100 + pct = float64(cov.Captured) / float64(cov.Total) * percentScale } sb.WriteString(fmt.Sprintf("| %s | %d | %d | %.1f%% |\n", service, cov.Total, cov.Captured, pct)) diff --git a/cmd/onvif-diagnostics/main.go b/cmd/onvif-diagnostics/main.go index da90911..2bcb4c6 100644 --- a/cmd/onvif-diagnostics/main.go +++ b/cmd/onvif-diagnostics/main.go @@ -30,6 +30,7 @@ const ( retryDelaySec = 5 maxIdleTimeoutSec = 90 unknownStatus = "Unknown" + percentScale = 100 ) type CameraReport struct { @@ -997,12 +998,24 @@ func runComprehensiveCapture(ctx context.Context, client *onvif.Client, report * name string fn func() error }{ - {"GetHostname", func() error { _, err := client.GetHostname(ctx); return err }}, - {"GetDNS", func() error { _, err := client.GetDNS(ctx); return err }}, - {"GetNTP", func() error { _, err := client.GetNTP(ctx); return err }}, - {"GetNetworkInterfaces", func() error { _, err := client.GetNetworkInterfaces(ctx); return err }}, - {"GetNetworkProtocols", func() error { _, err := client.GetNetworkProtocols(ctx); return err }}, - {"GetNetworkDefaultGateway", func() error { _, err := client.GetNetworkDefaultGateway(ctx); return err }}, + {"GetHostname", func() error { _, err := client.GetHostname(ctx); return fmt.Errorf("GetHostname: %w", err) }}, //nolint:nlreturn + {"GetDNS", func() error { _, err := client.GetDNS(ctx); return fmt.Errorf("GetDNS: %w", err) }}, //nolint:nlreturn + {"GetNTP", func() error { _, err := client.GetNTP(ctx); return fmt.Errorf("GetNTP: %w", err) }}, //nolint:nlreturn + {"GetNetworkInterfaces", func() error { + _, err := client.GetNetworkInterfaces(ctx) + + return fmt.Errorf("GetNetworkInterfaces: %w", err) + }}, + {"GetNetworkProtocols", func() error { + _, err := client.GetNetworkProtocols(ctx) + + return fmt.Errorf("GetNetworkProtocols: %w", err) + }}, + {"GetNetworkDefaultGateway", func() error { + _, err := client.GetNetworkDefaultGateway(ctx) + + return fmt.Errorf("GetNetworkDefaultGateway: %w", err) + }}, {"GetScopes", func() error { _, err := client.GetScopes(ctx); return err }}, {"GetUsers", func() error { _, err := client.GetUsers(ctx); return err }}, {"GetDiscoveryMode", func() error { _, err := client.GetDiscoveryMode(ctx); return err }}, @@ -1332,7 +1345,7 @@ func runComprehensiveCapture(ctx context.Context, client *onvif.Client, report * fmt.Printf(" Total operations: %d\n", totalOps) fmt.Printf(" Successful: %d\n", successCount) fmt.Printf(" Failed: %d\n", failCount) - fmt.Printf(" Success rate: %.1f%%\n", float64(successCount)/float64(totalOps)*100) + fmt.Printf(" Success rate: %.1f%%\n", float64(successCount)/float64(totalOps)*percentScale) fmt.Println("========================================") } @@ -1633,6 +1646,73 @@ func extractSOAPOperation(soapBody string) string { return "Unknown" } +// compareFileOrder determines sort order for tar archive entries. +// Returns true if file i should come before file j. +func compareFileOrder(i, j int, files []string) bool { + nameI := filepath.Base(files[i]) + nameJ := filepath.Base(files[j]) + + // metadata.json always first + if nameI == "metadata.json" { + return true + } + if nameJ == "metadata.json" { + return false + } + + // JSON files before XML files + isJSONi := strings.HasSuffix(nameI, ".json") + isJSONj := strings.HasSuffix(nameJ, ".json") + if isJSONi && !isJSONj { + return true + } + if !isJSONi && isJSONj { + return false + } + + // Sort by name + return nameI < nameJ +} + +// writeTarEntry writes a single file to the tar archive. +func writeTarEntry(tarWriter *tar.Writer, sourceDir, path string) error { + info, err := os.Stat(path) + if err != nil { + return fmt.Errorf("failed to stat file: %w", err) + } + + // Create tar header + header, err := tar.FileInfoHeader(info, "") + if err != nil { + return fmt.Errorf("failed to create tar header: %w", err) + } + + // Set name to relative path + relPath, err := filepath.Rel(sourceDir, path) + if err != nil { + return fmt.Errorf("failed to get relative path: %w", err) + } + header.Name = relPath + + // Write header + if err := tarWriter.WriteHeader(header); err != nil { + return fmt.Errorf("failed to write tar header: %w", err) + } + + // Write file content + file, err := os.Open(path) + if err != nil { + return fmt.Errorf("failed to open file: %w", err) + } + + if _, err := io.Copy(tarWriter, file); err != nil { + _ = file.Close() + return fmt.Errorf("failed to write file to tar: %w", err) + } + _ = file.Close() + return nil +} + // createTarGzV2 creates a V2 tar.gz archive with metadata.json first. func createTarGzV2(sourceDir, archivePath string) error { // Create archive file @@ -1673,67 +1753,14 @@ func createTarGzV2(sourceDir, archivePath string) error { // Sort files: metadata.json first, then capture JSON files in order, then XML files sort.Slice(files, func(i, j int) bool { - nameI := filepath.Base(files[i]) - nameJ := filepath.Base(files[j]) - - // metadata.json always first - if nameI == "metadata.json" { - return true - } - if nameJ == "metadata.json" { - return false - } - - // JSON files before XML files - isJSONi := strings.HasSuffix(nameI, ".json") - isJSONj := strings.HasSuffix(nameJ, ".json") - if isJSONi && !isJSONj { - return true - } - if !isJSONi && isJSONj { - return false - } - - // Sort by name - return nameI < nameJ + return compareFileOrder(i, j, files) }) // Write files in sorted order for _, path := range files { - info, err := os.Stat(path) - if err != nil { - return fmt.Errorf("failed to stat file: %w", err) + if err := writeTarEntry(tarWriter, sourceDir, path); err != nil { + return err } - - // Create tar header - header, err := tar.FileInfoHeader(info, "") - if err != nil { - return fmt.Errorf("failed to create tar header: %w", err) - } - - // Set name to relative path - relPath, err := filepath.Rel(sourceDir, path) - if err != nil { - return fmt.Errorf("failed to get relative path: %w", err) - } - header.Name = relPath - - // Write header - if err := tarWriter.WriteHeader(header); err != nil { - return fmt.Errorf("failed to write tar header: %w", err) - } - - // Write file content - file, err := os.Open(path) //nolint:gosec // File path is from filepath.Walk, safe - if err != nil { - return fmt.Errorf("failed to open file: %w", err) - } - - if _, err := io.Copy(tarWriter, file); err != nil { - _ = file.Close() - return fmt.Errorf("failed to write file to tar: %w", err) - } - _ = file.Close() } return nil diff --git a/device_security.go b/device_security.go index 8e61fb8..118131a 100644 --- a/device_security.go +++ b/device_security.go @@ -58,6 +58,7 @@ func buildIPAddressFilterRequest(filter *IPAddressFilter) ipAddressFilterRequest // newSOAPClient creates a SOAP client with the current client credentials. func (c *Client) newSOAPClient() *soap.Client { username, password := c.GetCredentials() + return soap.NewClient(c.httpClient, username, password) } diff --git a/go.mod b/go.mod index a0cc30b..2655e58 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ require github.com/0x524A/rtspeek v0.0.1 require ( github.com/bluenviron/gortsplib/v4 v4.16.2 // indirect github.com/bluenviron/mediacommon/v2 v2.4.1 // indirect + github.com/fzipp/gocyclo v0.6.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.19 // indirect diff --git a/go.sum b/go.sum index 6931161..ac49297 100644 --- a/go.sum +++ b/go.sum @@ -7,6 +7,8 @@ github.com/bluenviron/mediacommon/v2 v2.4.1/go.mod h1:a6MbPmXtYda9mKibKVMZlW20GY github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fzipp/gocyclo v0.6.0 h1:lsblElZG7d3ALtGMx9fmxeTKZaLLpU8mET09yN4BBLo= +github.com/fzipp/gocyclo v0.6.0/go.mod h1:rXPyn8fnlpa0R2csP/31uerbiVBugk5whMdlyaLkLoA= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= diff --git a/ptz.go b/ptz.go index 4d9e099..763d4b9 100644 --- a/ptz.go +++ b/ptz.go @@ -48,6 +48,7 @@ func convertToPTZVectorXML(v *PTZVector) *ptzVectorXML { if v.Zoom != nil { result.Zoom = &ptzZoomXML{X: v.Zoom.X, Space: v.Zoom.Space} } + return result } @@ -63,6 +64,7 @@ func convertToPTZSpeedXML(s *PTZSpeed) *ptzSpeedXML { if s.Zoom != nil { result.Zoom = &ptzZoomXML{X: s.Zoom.X, Space: s.Zoom.Space} } + return result } diff --git a/testing/capture_types.go b/testing/capture_types.go index 01e8110..2a8d37b 100644 --- a/testing/capture_types.go +++ b/testing/capture_types.go @@ -273,9 +273,19 @@ func BuildMatchKeyFromExchange(exchange *CapturedExchangeV2) MatchKey { } } +// addTokenScore adds tokenScoreBonus points to score if token matches between two MatchKeys. +const tokenScoreBonus = 10 + +func addTokenScore(score int, token1, token2 string) int { + if token1 != "" && token1 == token2 { + return score + tokenScoreBonus + } + return score +} + // MatchScore returns how well two MatchKeys match (higher is better). // Returns -1 if operation names don't match. -func (k MatchKey) MatchScore(other MatchKey) int { +func (k *MatchKey) MatchScore(other *MatchKey) int { if k.OperationName != other.OperationName { return -1 } @@ -283,27 +293,13 @@ func (k MatchKey) MatchScore(other MatchKey) int { score := 1 // Base score for matching operation // Bonus points for matching parameters - if k.ProfileToken != "" && k.ProfileToken == other.ProfileToken { - score += 10 - } - if k.ConfigurationToken != "" && k.ConfigurationToken == other.ConfigurationToken { - score += 10 - } - if k.VideoSourceToken != "" && k.VideoSourceToken == other.VideoSourceToken { - score += 10 - } - if k.AudioSourceToken != "" && k.AudioSourceToken == other.AudioSourceToken { - score += 10 - } - if k.PresetToken != "" && k.PresetToken == other.PresetToken { - score += 10 - } - if k.NodeToken != "" && k.NodeToken == other.NodeToken { - score += 10 - } - if k.OSDToken != "" && k.OSDToken == other.OSDToken { - score += 10 - } + score = addTokenScore(score, k.ProfileToken, other.ProfileToken) + score = addTokenScore(score, k.ConfigurationToken, other.ConfigurationToken) + score = addTokenScore(score, k.VideoSourceToken, other.VideoSourceToken) + score = addTokenScore(score, k.AudioSourceToken, other.AudioSourceToken) + score = addTokenScore(score, k.PresetToken, other.PresetToken) + score = addTokenScore(score, k.NodeToken, other.NodeToken) + score = addTokenScore(score, k.OSDToken, other.OSDToken) return score } diff --git a/testing/capture_types_test.go b/testing/capture_types_test.go index 0aaed44..b7df110 100644 --- a/testing/capture_types_test.go +++ b/testing/capture_types_test.go @@ -156,7 +156,7 @@ func TestMatchKey_MatchScore(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - if result := tt.key1.MatchScore(tt.key2); result != tt.expected { + if result := tt.key1.MatchScore(&tt.key2); result != tt.expected { t.Errorf("MatchScore() = %v, want %v", result, tt.expected) } }) diff --git a/testing/golden.go b/testing/golden.go index 69ef307..ccfee59 100644 --- a/testing/golden.go +++ b/testing/golden.go @@ -2,6 +2,7 @@ package onviftesting import ( + "bytes" "encoding/json" "fmt" "os" @@ -40,7 +41,7 @@ type GoldenFileSet struct { // LoadGoldenManifest loads a manifest.json from a golden directory. func LoadGoldenManifest(goldenDir string) (*GoldenManifest, error) { manifestPath := filepath.Join(goldenDir, "manifest.json") - data, err := os.ReadFile(manifestPath) + data, err := os.ReadFile(manifestPath) //nolint:gosec // Path is from test data directory, safe if err != nil { return nil, fmt.Errorf("failed to read manifest: %w", err) } @@ -82,7 +83,7 @@ func LoadGoldenFiles(goldenDir string) (*GoldenFileSet, error) { return nil } - data, err := os.ReadFile(path) + data, err := os.ReadFile(path) //nolint:gosec // Path is from filepath.Walk, safe if err != nil { return fmt.Errorf("failed to read %s: %w", path, err) } @@ -100,7 +101,7 @@ func LoadGoldenFiles(goldenDir string) (*GoldenFileSet, error) { }) if err != nil { - return nil, err + return nil, fmt.Errorf("failed to load golden files: %w", err) } return set, nil @@ -171,6 +172,7 @@ func ValidateResponse(response interface{}, golden *GoldenFile) []string { actual, ok := responseData[field] if !ok { errors = append(errors, fmt.Sprintf("missing field: %s", field)) + continue } @@ -192,12 +194,12 @@ func ValidateResponse(response interface{}, golden *GoldenFile) []string { func toMap(v interface{}) (map[string]interface{}, error) { data, err := json.Marshal(v) if err != nil { - return nil, err + return nil, fmt.Errorf("failed to marshal value: %w", err) } var result map[string]interface{} if err := json.Unmarshal(data, &result); err != nil { - return nil, err + return nil, fmt.Errorf("failed to unmarshal to map: %w", err) } return result, nil @@ -230,7 +232,7 @@ func valuesEqual(expected, actual interface{}) bool { return false } - return string(e) == string(a) + return bytes.Equal(e, a) } // SaveGoldenFile saves a golden file to disk. diff --git a/testing/mock_server.go b/testing/mock_server.go index 9df584a..5bc9525 100644 --- a/testing/mock_server.go +++ b/testing/mock_server.go @@ -263,6 +263,58 @@ func NewMockSOAPServerV2(archivePath string) (*MockSOAPServerV2, error) { return mock, nil } +// processArchiveEntry processes a single tar archive entry (JSON file) and adds it to the capture. +// Returns (isMetadata, error). +func processArchiveEntry(header *tar.Header, data []byte, capture *CameraCaptureV2) (*CaptureMetadata, error) { + // Check for metadata.json (V2 archives) + if header.Name == "metadata.json" || strings.HasSuffix(header.Name, "/metadata.json") { + var meta CaptureMetadata + if err := json.Unmarshal(data, &meta); err != nil { + return nil, fmt.Errorf("failed to unmarshal metadata: %w", err) + } + return &meta, nil + } + + // Skip files that look like request/response XML stored as JSON + if strings.Contains(header.Name, "_request") || strings.Contains(header.Name, "_response") { + return nil, nil + } + + // Parse exchange from JSON + exchange, err := parseExchange(header.Name, data) + if err != nil { + return nil, err + } + if exchange != nil { + capture.Exchanges = append(capture.Exchanges, *exchange) + } + + return nil, nil +} + +// parseExchange parses a JSON exchange entry, supporting both V1 and V2 formats. +func parseExchange(fileName string, data []byte) (*CapturedExchangeV2, error) { + version := DetectCaptureVersion(data) + if version >= "2.0" { + var exchange CapturedExchangeV2 + if err := json.Unmarshal(data, &exchange); err != nil { + return nil, fmt.Errorf("failed to unmarshal V2 %s: %w", fileName, err) + } + return &exchange, nil + } + + // V1 format - convert to V2 + var v1Exchange CapturedExchange + if err := json.Unmarshal(data, &v1Exchange); err != nil { + return nil, fmt.Errorf("failed to unmarshal V1 %s: %w", fileName, err) + } + v2Exchange := ConvertV1ToV2(&v1Exchange) + // Extract parameters from V1 request body + v2Exchange.Parameters = ExtractParameters(v2Exchange.OperationName, v2Exchange.RequestBody) + v2Exchange.ServiceType = DetermineServiceType(v2Exchange.RequestBody) + return v2Exchange, nil +} + // LoadCaptureFromArchiveV2 loads captures from archive, supporting both V1 and V2 formats. func LoadCaptureFromArchiveV2(archivePath string) (*CameraCaptureV2, *CaptureMetadata, error) { file, err := os.Open(archivePath) //nolint:gosec // File path is from test data, safe @@ -308,40 +360,13 @@ func LoadCaptureFromArchiveV2(archivePath string) (*CameraCaptureV2, *CaptureMet return nil, nil, fmt.Errorf("failed to read file %s: %w", header.Name, err) } - // Check for metadata.json (V2 archives) - if header.Name == "metadata.json" || strings.HasSuffix(header.Name, "/metadata.json") { - var meta CaptureMetadata - if err := json.Unmarshal(data, &meta); err != nil { - return nil, nil, fmt.Errorf("failed to unmarshal metadata: %w", err) - } - metadata = &meta - continue + // Process the archive entry + meta, err := processArchiveEntry(header, data, capture) + if err != nil { + return nil, nil, err } - - // Skip files that look like request/response XML stored as JSON - if strings.Contains(header.Name, "_request") || strings.Contains(header.Name, "_response") { - continue - } - - // Detect version and unmarshal accordingly - version := DetectCaptureVersion(data) - if version >= "2.0" { - var exchange CapturedExchangeV2 - if err := json.Unmarshal(data, &exchange); err != nil { - return nil, nil, fmt.Errorf("failed to unmarshal V2 %s: %w", header.Name, err) - } - capture.Exchanges = append(capture.Exchanges, exchange) - } else { - // V1 format - convert to V2 - var v1Exchange CapturedExchange - if err := json.Unmarshal(data, &v1Exchange); err != nil { - return nil, nil, fmt.Errorf("failed to unmarshal V1 %s: %w", header.Name, err) - } - v2Exchange := ConvertV1ToV2(&v1Exchange) - // Extract parameters from V1 request body - v2Exchange.Parameters = ExtractParameters(v2Exchange.OperationName, v2Exchange.RequestBody) - v2Exchange.ServiceType = DetermineServiceType(v2Exchange.RequestBody) - capture.Exchanges = append(capture.Exchanges, *v2Exchange) + if meta != nil { + metadata = meta } } @@ -380,7 +405,7 @@ func (m *MockSOAPServerV2) handleRequest(w http.ResponseWriter, r *http.Request) for _, ex := range exchanges { exchangeKey := BuildMatchKeyFromExchange(ex) - score := requestKey.MatchScore(exchangeKey) + score := requestKey.MatchScore(&exchangeKey) if score > bestScore { bestScore = score bestMatch = ex @@ -496,6 +521,7 @@ func ExtractParameters(operationName, soapBody string) map[string]interface{} { for i := 1; i < len(matches); i++ { if matches[i] != "" { params[paramName] = strings.TrimSpace(matches[i]) + break } } diff --git a/testing/operations.go b/testing/operations.go index bf74d98..99604af 100644 --- a/testing/operations.go +++ b/testing/operations.go @@ -417,9 +417,10 @@ func ReadOperationsByService(service ServiceType) []OperationSpec { return EventReadOperations case ServiceDeviceIO: return DeviceIOReadOperations - default: + case ServiceUnknown: return nil } + return nil } // IndependentOperations returns operations that don't depend on other operations. diff --git a/testing/registry.go b/testing/registry.go index 3b6a64c..61a9902 100644 --- a/testing/registry.go +++ b/testing/registry.go @@ -9,6 +9,8 @@ import ( "time" ) +const percentScale = 100 + // Registry holds information about all available camera captures. type Registry struct { Version string `json:"version"` @@ -47,7 +49,7 @@ const DefaultRegistryPath = "testdata/captures/registry.json" // LoadRegistry loads the capture registry from a file. func LoadRegistry(path string) (*Registry, error) { - data, err := os.ReadFile(path) + data, err := os.ReadFile(path) //nolint:gosec // Registry path is from constant or test data, safe if err != nil { if os.IsNotExist(err) { // Return empty registry if file doesn't exist @@ -92,12 +94,13 @@ func SaveRegistry(registry *Registry, path string) error { } // AddCamera adds a new camera to the registry. -func (r *Registry) AddCamera(entry CameraEntry) { +func (r *Registry) AddCamera(entry *CameraEntry) { // Check if camera already exists - for i, cam := range r.Cameras { + for i := range r.Cameras { + cam := &r.Cameras[i] if cam.ID == entry.ID { // Update existing entry - r.Cameras[i] = entry + r.Cameras[i] = *entry return } } @@ -106,7 +109,7 @@ func (r *Registry) AddCamera(entry CameraEntry) { if entry.AddedDate == "" { entry.AddedDate = time.Now().Format("2006-01-02") } - r.Cameras = append(r.Cameras, entry) + r.Cameras = append(r.Cameras, *entry) } // GetCamera retrieves a camera entry by ID. @@ -121,21 +124,23 @@ func (r *Registry) GetCamera(id string) *CameraEntry { // RemoveCamera removes a camera from the registry. func (r *Registry) RemoveCamera(id string) bool { - for i, cam := range r.Cameras { + for i := range r.Cameras { + cam := &r.Cameras[i] if cam.ID == id { r.Cameras = append(r.Cameras[:i], r.Cameras[i+1:]...) return true } } + return false } // GetCamerasByManufacturer returns all cameras from a specific manufacturer. -func (r *Registry) GetCamerasByManufacturer(manufacturer string) []CameraEntry { - var cameras []CameraEntry - for _, cam := range r.Cameras { - if cam.Manufacturer == manufacturer { - cameras = append(cameras, cam) +func (r *Registry) GetCamerasByManufacturer(manufacturer string) []*CameraEntry { + var cameras []*CameraEntry + for i := range r.Cameras { + if r.Cameras[i].Manufacturer == manufacturer { + cameras = append(cameras, &r.Cameras[i]) } } return cameras @@ -164,7 +169,7 @@ func (r *Registry) UpdateCoverage() { } // GetTotalCoverage returns the total coverage across all services. -func (r *Registry) GetTotalCoverage() (total int, captured int) { +func (r *Registry) GetTotalCoverage() (total, captured int) { for _, cov := range r.Coverage { total += cov.Total captured += cov.Captured @@ -203,7 +208,8 @@ func sanitizeID(s string) string { func ValidateRegistry(registry *Registry, basePath string) []string { var errors []string - for _, cam := range registry.Cameras { + for i := range registry.Cameras { + cam := ®istry.Cameras[i] capturePath := filepath.Join(basePath, cam.CaptureFile) if _, err := os.Stat(capturePath); os.IsNotExist(err) { errors = append(errors, fmt.Sprintf("camera %s: capture file not found: %s", cam.ID, cam.CaptureFile)) @@ -233,11 +239,13 @@ func CreateCameraEntryFromCapture(archivePath string) (*CameraEntry, error) { cameraInfo = metadata.CameraInfo } else { // Try to extract from GetDeviceInformation response - for _, ex := range capture.Exchanges { + for i := range capture.Exchanges { + ex := &capture.Exchanges[i] if ex.OperationName == "GetDeviceInformation" { cameraInfo.Manufacturer = ExtractXMLElement(ex.ResponseBody, "Manufacturer") cameraInfo.Model = ExtractXMLElement(ex.ResponseBody, "Model") cameraInfo.FirmwareVersion = ExtractXMLElement(ex.ResponseBody, "FirmwareVersion") + break } } @@ -268,7 +276,8 @@ func CreateCameraEntryFromCapture(archivePath string) (*CameraEntry, error) { func detectCapabilities(capture *CameraCaptureV2) []string { services := make(map[string]bool) - for _, ex := range capture.Exchanges { + for i := range capture.Exchanges { + ex := &capture.Exchanges[i] if ex.ServiceType != "" { services[string(ex.ServiceType)] = true } else { @@ -280,10 +289,11 @@ func detectCapabilities(capture *CameraCaptureV2) []string { } } - var result []string + result := make([]string, 0, len(services)) for svc := range services { result = append(result, svc) } + return result } @@ -349,8 +359,8 @@ func (r *Registry) GetSummary() RegistrySummary { } // Count by manufacturer - for _, cam := range r.Cameras { - summary.ManufacturerCount[cam.Manufacturer]++ + for i := range r.Cameras { + summary.ManufacturerCount[r.Cameras[i].Manufacturer]++ } // Calculate coverage percentages @@ -358,7 +368,7 @@ func (r *Registry) GetSummary() RegistrySummary { summary.TotalOperations += cov.Total summary.CapturedOperations += cov.Captured if cov.Total > 0 { - summary.ServiceCoverage[service] = float64(cov.Captured) / float64(cov.Total) * 100 + summary.ServiceCoverage[service] = float64(cov.Captured) / float64(cov.Total) * percentScale } }