Add camera test framework and initial tests for Bosch FLEXIDOME indoor 5100i IR

- Introduced a new directory `testdata/captures/` containing captured XML archives and README documentation for the camera test framework.
- Added a mock server implementation to replay captured SOAP responses for testing.
- Created automated tests for Bosch FLEXIDOME indoor 5100i IR using captured responses, validating device information, system date and time, capabilities, and profiles.
- Implemented enhanced device features tests, covering hostname, DNS, NTP, network interfaces, scopes, and user management.
- Added support for enhanced media and imaging features, including video and audio sources, and imaging options.
- Updated types to include new configurations and options for network, imaging, and device capabilities.
This commit is contained in:
ProtoTess
2025-11-11 02:10:04 +00:00
parent 3340094f4f
commit 3bf078ed3f
27 changed files with 5701 additions and 147 deletions
+298
View File
@@ -0,0 +1,298 @@
# Camera Test Framework
This directory contains camera-specific tests generated from real camera XML captures. These tests ensure the ONVIF client works correctly with various camera models and prevents regressions when making changes.
## Overview
The test framework consists of:
1. **Captured XML Archives** (`*.tar.gz`) - Real SOAP XML request/response pairs from cameras
2. **Generated Tests** (`*_test.go`) - Automated tests that replay captures through a mock server
3. **Test Generator** (`cmd/generate-tests`) - Tool to create tests from captures
4. **Mock Server** (`testing/mock_server.go`) - HTTP server that replays captured responses
## Benefits
**Test Without Hardware** - Run ONVIF tests without needing physical cameras
**Prevent Regressions** - Catch breaking changes before they affect real deployments
**Camera Coverage** - Test against multiple camera manufacturers and models
**Fast Feedback** - Tests complete in milliseconds vs. minutes with real cameras
**CI/CD Ready** - Automated tests that can run in continuous integration
## Running Tests
### Run All Camera Tests
```bash
go test -v ./testdata/captures/
```
### Run Specific Camera
```bash
go test -v ./testdata/captures/ -run TestBosch
```
### Run from Project Root
```bash
go test -v ./...
```
## Adding New Camera Tests
### 1. Capture Camera XML
First, capture SOAP XML from your camera:
```bash
# Run diagnostic with XML capture
./onvif-diagnostics \
-endpoint "http://camera-ip/onvif/device_service" \
-username "user" \
-password "pass" \
-capture-xml \
-verbose
```
This creates an archive like:
```
camera-logs/Manufacturer_Model_Firmware_xmlcapture_timestamp.tar.gz
```
### 2. Copy to testdata/captures
```bash
cp camera-logs/Manufacturer_Model_*_xmlcapture_*.tar.gz testdata/captures/
```
### 3. Generate Test
```bash
./generate-tests \
-capture testdata/captures/Manufacturer_Model_*_xmlcapture_*.tar.gz \
-output testdata/captures/
```
This generates:
```
testdata/captures/manufacturer_model_firmware_test.go
```
### 4. Run the Test
```bash
go test -v ./testdata/captures/ -run TestManufacturerModel
```
## Example Workflow
Complete example adding an AXIS camera:
```bash
# 1. Capture from camera
./onvif-diagnostics \
-endpoint "http://192.168.1.100/onvif/device_service" \
-username "root" \
-password "pass" \
-capture-xml
# Output: camera-logs/AXIS_Q3626-VE_12.6.104_xmlcapture_20251110-130000.tar.gz
# 2. Copy to testdata
cp camera-logs/AXIS_Q3626-VE_12.6.104_xmlcapture_20251110-130000.tar.gz testdata/captures/
# 3. Generate test
./generate-tests \
-capture testdata/captures/AXIS_Q3626-VE_12.6.104_xmlcapture_20251110-130000.tar.gz \
-output testdata/captures/
# Output: testdata/captures/axis_q3626-ve_12.6.104_test.go
# 4. Run test
go test -v ./testdata/captures/ -run TestAXIS
```
## Directory Structure
```
testdata/captures/
├── README.md # This file
├── Bosch_FLEXIDOME_indoor_5100i_IR_8.71.0066_xmlcapture_*.tar.gz # Capture archive
├── bosch_flexidome_indoor_5100i_ir_8.71.0066_test.go # Generated test
├── AXIS_Q3626-VE_12.6.104_xmlcapture_*.tar.gz # Another camera
└── axis_q3626-ve_12.6.104_test.go # Its test
```
## How It Works
### Capture Archive Contents
Each `*.tar.gz` archive contains:
```
capture_001.json # Request/response metadata
capture_001_request.xml # SOAP request
capture_001_response.xml # SOAP response
capture_002.json
capture_002_request.xml
capture_002_response.xml
...
```
### Mock Server
The test framework includes a mock HTTP server that:
1. Loads all captured exchanges from the archive
2. Extracts SOAP operation names from requests (GetDeviceInformation, GetProfiles, etc.)
3. Matches incoming test requests to captured responses by operation name
4. Returns the exact SOAP response the real camera sent
This allows the ONVIF client to interact with "virtual cameras" that behave exactly like the real ones.
### Generated Test
Each generated test:
1. Creates a mock server from the capture archive
2. Creates an ONVIF client pointing to the mock server
3. Runs common ONVIF operations (GetDeviceInformation, GetProfiles, etc.)
4. Validates responses match expected values
## Customizing Tests
### Adding Custom Assertions
Edit the generated test file to add camera-specific validations:
```go
t.Run("GetDeviceInformation", func(t *testing.T) {
info, err := client.GetDeviceInformation(ctx)
if err != nil {
t.Errorf("GetDeviceInformation failed: %v", err)
return
}
// Add custom assertions
if info.Manufacturer != "Bosch" {
t.Errorf("Expected Bosch, got %s", info.Manufacturer)
}
if !strings.Contains(info.Model, "FLEXIDOME") {
t.Errorf("Expected FLEXIDOME model, got %s", info.Model)
}
})
```
### Testing Specific Operations
Add tests for camera-specific features:
```go
t.Run("PTZPresets", func(t *testing.T) {
// Only for PTZ cameras
presets, err := client.GetPresets(ctx, "profile_token")
if err != nil {
t.Errorf("GetPresets failed: %v", err)
return
}
if len(presets) == 0 {
t.Error("Expected at least one preset")
}
})
```
## Troubleshooting
### Test Fails: "No matching capture found"
The mock server couldn't find a captured response for the operation.
**Solution**: Re-capture from the camera to include all operations.
### Test Fails: Unexpected Response
The client is receiving the wrong SOAP response.
**Solution**: Check that operation names match. The mock server matches by SOAP operation name extracted from the `<Body>` element.
### Archive Not Found
```
Failed to create mock server: failed to open archive: no such file or directory
```
**Solution**: Ensure the capture archive is in `testdata/captures/` directory.
## Maintenance
### Updating Captures
When camera firmware changes:
1. Re-run diagnostics with `-capture-xml`
2. Replace old capture archive
3. Regenerate test (or manually update paths)
4. Re-run tests to verify
### Cleaning Up
Remove old captures and tests:
```bash
rm testdata/captures/old_camera_*.tar.gz
rm testdata/captures/old_camera_test.go
```
## CI/CD Integration
### GitHub Actions
```yaml
name: Camera Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-go@v4
with:
go-version: '1.21'
- name: Run Camera Tests
run: go test -v ./testdata/captures/
```
### Benefits in CI
- Tests run on every commit
- Prevents merging code that breaks camera compatibility
- No need for test cameras in CI environment
- Fast execution (< 1 second for all cameras)
## Best Practices
1. **Capture from latest firmware** - Use up-to-date camera firmware
2. **Include all operations** - Run full diagnostic to capture all SOAP operations
3. **Document camera models** - Add comments in tests noting camera specifics
4. **Version control captures** - Commit `.tar.gz` files to track camera behavior over time
5. **Test before changes** - Run tests before making client changes to establish baseline
6. **Test after changes** - Verify all camera tests pass after modifications
## Related Tools
- **onvif-diagnostics** - Captures XML from cameras (`cmd/onvif-diagnostics`)
- **generate-tests** - Creates tests from captures (`cmd/generate-tests`)
- **mock_server** - Test server implementation (`testing/mock_server.go`)
## Support
For issues or questions:
1. Check that capture archive is valid (can extract with `tar -xzf`)
2. Verify test file package is `onvif_test`
3. Run with `-v` flag for verbose output
4. Check `testing/mock_server.go` logs for operation matching details
@@ -0,0 +1,98 @@
package onvif_test
import (
"context"
"testing"
"time"
"github.com/0x524A/go-onvif"
onviftesting "github.com/0x524A/go-onvif/testing"
)
// TestBosch_FLEXIDOME_indoor_5100i_IR_8710066 tests ONVIF client against Bosch_FLEXIDOME_indoor_5100i_IR_8.71.0066 captured responses
func TestBosch_FLEXIDOME_indoor_5100i_IR_8710066(t *testing.T) {
// Load capture archive (in same directory as test)
captureArchive := "Bosch_FLEXIDOME_indoor_5100i_IR_8.71.0066_xmlcapture_20251110-123259.tar.gz"
mockServer, err := onviftesting.NewMockSOAPServer(captureArchive)
if err != nil {
t.Fatalf("Failed to create mock server: %v", err)
}
defer mockServer.Close()
// Create ONVIF client pointing to mock server
client, err := onvif.NewClient(
mockServer.URL()+"/onvif/device_service",
onvif.WithCredentials("testuser", "testpass"),
)
if err != nil {
t.Fatalf("Failed to create ONVIF client: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
t.Run("GetDeviceInformation", func(t *testing.T) {
info, err := client.GetDeviceInformation(ctx)
if err != nil {
t.Errorf("GetDeviceInformation failed: %v", err)
return
}
// Validate expected values
if info.Manufacturer == "" {
t.Error("Manufacturer is empty")
}
if info.Model == "" {
t.Error("Model is empty")
}
if info.FirmwareVersion == "" {
t.Error("FirmwareVersion is empty")
}
t.Logf("Device: %s %s (Firmware: %s)", info.Manufacturer, info.Model, info.FirmwareVersion)
})
t.Run("GetSystemDateAndTime", func(t *testing.T) {
_, err := client.GetSystemDateAndTime(ctx)
if err != nil {
t.Errorf("GetSystemDateAndTime failed: %v", err)
}
})
t.Run("GetCapabilities", func(t *testing.T) {
caps, err := client.GetCapabilities(ctx)
if err != nil {
t.Errorf("GetCapabilities failed: %v", err)
return
}
if caps.Device == nil {
t.Error("Device capabilities is nil")
}
if caps.Media == nil {
t.Error("Media capabilities is nil")
}
t.Logf("Capabilities: Device=%v, Media=%v, Imaging=%v, PTZ=%v",
caps.Device != nil, caps.Media != nil, caps.Imaging != nil, caps.PTZ != nil)
})
t.Run("GetProfiles", func(t *testing.T) {
profiles, err := client.GetProfiles(ctx)
if err != nil {
t.Errorf("GetProfiles failed: %v", err)
return
}
if len(profiles) == 0 {
t.Error("No profiles returned")
}
t.Logf("Found %d profile(s)", len(profiles))
for i, profile := range profiles {
t.Logf(" Profile %d: %s (Token: %s)", i+1, profile.Name, profile.Token)
}
})
}
+367
View File
@@ -0,0 +1,367 @@
package onvif
package captures
import (
"context"
"testing"
"time"
"github.com/0x524A/go-onvif"
)
// TestEnhancedDeviceFeatures tests new Device service methods with real camera data
// Based on test results from Bosch FLEXIDOME indoor 5100i IR (8.71.0066)
func TestEnhancedDeviceFeatures(t *testing.T) {
// Create client with test credentials
client, err := onvif.NewClient(
"http://192.168.1.201/onvif/device_service",
onvif.WithCredentials("service", "Service.1234"),
onvif.WithTimeout(30*time.Second),
)
if err != nil {
t.Fatalf("Failed to create client: %v", err)
}
ctx := context.Background()
t.Run("GetHostname", func(t *testing.T) {
hostname, err := client.GetHostname(ctx)
if err != nil {
t.Fatalf("GetHostname failed: %v", err)
}
// Bosch camera has hostname configuration
if hostname == nil {
t.Fatal("Expected hostname information, got nil")
}
t.Logf("Hostname: FromDHCP=%v, Name=%q", hostname.FromDHCP, hostname.Name)
})
t.Run("GetDNS", func(t *testing.T) {
dns, err := client.GetDNS(ctx)
if err != nil {
t.Fatalf("GetDNS failed: %v", err)
}
if dns == nil {
t.Fatal("Expected DNS information, got nil")
}
// Bosch camera uses DHCP for DNS
if !dns.FromDHCP {
t.Logf("Note: Camera not using DHCP for DNS")
}
// Should have at least one DNS server
if len(dns.DNSFromDHCP) == 0 && len(dns.DNSManual) == 0 {
t.Error("Expected at least one DNS server")
}
t.Logf("DNS: FromDHCP=%v, Servers=%d (DHCP) + %d (Manual)",
dns.FromDHCP, len(dns.DNSFromDHCP), len(dns.DNSManual))
})
t.Run("GetNTP", func(t *testing.T) {
ntp, err := client.GetNTP(ctx)
if err != nil {
t.Fatalf("GetNTP failed: %v", err)
}
if ntp == nil {
t.Fatal("Expected NTP information, got nil")
}
// Bosch camera uses DHCP for NTP
if !ntp.FromDHCP {
t.Logf("Note: Camera not using DHCP for NTP")
}
t.Logf("NTP: FromDHCP=%v, Servers=%d (DHCP) + %d (Manual)",
ntp.FromDHCP, len(ntp.NTPFromDHCP), len(ntp.NTPManual))
})
t.Run("GetNetworkInterfaces", func(t *testing.T) {
interfaces, err := client.GetNetworkInterfaces(ctx)
if err != nil {
t.Fatalf("GetNetworkInterfaces failed: %v", err)
}
// Bosch camera has 1 network interface
if len(interfaces) == 0 {
t.Fatal("Expected at least one network interface")
}
iface := interfaces[0]
if iface.Token == "" {
t.Error("Expected interface to have token")
}
if iface.Info.Name == "" {
t.Error("Expected interface to have name")
}
if iface.Info.HwAddress == "" {
t.Error("Expected interface to have hardware address")
}
// Bosch camera has MTU of 1514
if iface.Info.MTU == 0 {
t.Error("Expected interface to have MTU")
}
t.Logf("Interface: Token=%s, Name=%s, HwAddr=%s, MTU=%d",
iface.Token, iface.Info.Name, iface.Info.HwAddress, iface.Info.MTU)
if iface.IPv4 != nil {
t.Logf(" IPv4: Enabled=%v, DHCP=%v",
iface.IPv4.Enabled, iface.IPv4.Config.DHCP)
}
})
t.Run("GetScopes", func(t *testing.T) {
scopes, err := client.GetScopes(ctx)
if err != nil {
t.Fatalf("GetScopes failed: %v", err)
}
// Bosch camera has 8 scopes
if len(scopes) == 0 {
t.Fatal("Expected at least one scope")
}
// Check for expected scopes
foundManufacturer := false
foundType := false
foundProfiles := 0
for _, scope := range scopes {
if scope.ScopeItem == "onvif://www.onvif.org/name/Bosch" {
foundManufacturer = true
}
if scope.ScopeItem == "onvif://www.onvif.org/type/Network_Video_Transmitter" {
foundType = true
}
// Count ONVIF profiles
if len(scope.ScopeItem) > 30 && scope.ScopeItem[:30] == "onvif://www.onvif.org/Profile/" {
foundProfiles++
}
}
if !foundManufacturer {
t.Error("Expected to find manufacturer scope")
}
if !foundType {
t.Error("Expected to find device type scope")
}
t.Logf("Scopes: Total=%d, Manufacturer=%v, Type=%v, Profiles=%d",
len(scopes), foundManufacturer, foundType, foundProfiles)
})
t.Run("GetUsers", func(t *testing.T) {
users, err := client.GetUsers(ctx)
if err != nil {
t.Fatalf("GetUsers failed: %v", err)
}
// Bosch camera has 3 users
if len(users) == 0 {
t.Fatal("Expected at least one user")
}
// Verify user levels
userLevels := make(map[string]int)
for _, user := range users {
if user.Username == "" {
t.Error("Expected user to have username")
}
if user.UserLevel == "" {
t.Error("Expected user to have level")
}
userLevels[user.UserLevel]++
}
t.Logf("Users: Total=%d, Administrator=%d, Operator=%d, User=%d",
len(users),
userLevels["Administrator"],
userLevels["Operator"],
userLevels["User"])
})
}
// TestEnhancedMediaFeatures tests new Media service methods
func TestEnhancedMediaFeatures(t *testing.T) {
client, err := onvif.NewClient(
"http://192.168.1.201/onvif/device_service",
onvif.WithCredentials("service", "Service.1234"),
onvif.WithTimeout(30*time.Second),
)
if err != nil {
t.Fatalf("Failed to create client: %v", err)
}
ctx := context.Background()
// Initialize to get media endpoint
if err := client.Initialize(ctx); err != nil {
t.Logf("Warning: Initialize failed: %v", err)
}
t.Run("GetVideoSources", func(t *testing.T) {
sources, err := client.GetVideoSources(ctx)
if err != nil {
t.Fatalf("GetVideoSources failed: %v", err)
}
// Bosch camera has 1 video source
if len(sources) == 0 {
t.Fatal("Expected at least one video source")
}
source := sources[0]
if source.Token == "" {
t.Error("Expected source to have token")
}
// Bosch camera supports 30fps
if source.Framerate == 0 {
t.Error("Expected source to have framerate")
}
// Bosch camera has 1920x1080 resolution
if source.Resolution == nil {
t.Error("Expected source to have resolution")
} else {
if source.Resolution.Width == 0 || source.Resolution.Height == 0 {
t.Error("Expected valid resolution dimensions")
}
t.Logf("Video Source: Token=%s, Framerate=%.1ffps, Resolution=%dx%d",
source.Token, source.Framerate,
source.Resolution.Width, source.Resolution.Height)
}
})
t.Run("GetAudioSources", func(t *testing.T) {
sources, err := client.GetAudioSources(ctx)
if err != nil {
t.Fatalf("GetAudioSources failed: %v", err)
}
// Bosch camera has 1 audio source with 2 channels
if len(sources) == 0 {
t.Fatal("Expected at least one audio source")
}
source := sources[0]
if source.Token == "" {
t.Error("Expected source to have token")
}
t.Logf("Audio Source: Token=%s, Channels=%d",
source.Token, source.Channels)
})
t.Run("GetAudioOutputs", func(t *testing.T) {
outputs, err := client.GetAudioOutputs(ctx)
if err != nil {
t.Fatalf("GetAudioOutputs failed: %v", err)
}
// Bosch camera has 1 audio output
if len(outputs) == 0 {
t.Fatal("Expected at least one audio output")
}
output := outputs[0]
if output.Token == "" {
t.Error("Expected output to have token")
}
t.Logf("Audio Output: Token=%s", output.Token)
})
}
// TestEnhancedImagingFeatures tests new Imaging service methods
func TestEnhancedImagingFeatures(t *testing.T) {
client, err := onvif.NewClient(
"http://192.168.1.201/onvif/device_service",
onvif.WithCredentials("service", "Service.1234"),
onvif.WithTimeout(30*time.Second),
)
if err != nil {
t.Fatalf("Failed to create client: %v", err)
}
ctx := context.Background()
// Initialize to get imaging endpoint
if err := client.Initialize(ctx); err != nil {
t.Logf("Warning: Initialize failed: %v", err)
}
// Get video source token
sources, err := client.GetVideoSources(ctx)
if err != nil || len(sources) == 0 {
t.Skip("No video sources available for imaging tests")
}
videoSourceToken := sources[0].Token
t.Run("GetOptions", func(t *testing.T) {
options, err := client.GetOptions(ctx, videoSourceToken)
if err != nil {
t.Fatalf("GetOptions failed: %v", err)
}
if options == nil {
t.Fatal("Expected imaging options, got nil")
}
// Bosch camera supports brightness (0-255)
if options.Brightness != nil {
if options.Brightness.Min > options.Brightness.Max {
t.Error("Expected Min <= Max for brightness")
}
t.Logf("Brightness range: %.0f - %.0f",
options.Brightness.Min, options.Brightness.Max)
}
// Bosch camera supports color saturation (0-255)
if options.ColorSaturation != nil {
if options.ColorSaturation.Min > options.ColorSaturation.Max {
t.Error("Expected Min <= Max for color saturation")
}
t.Logf("ColorSaturation range: %.0f - %.0f",
options.ColorSaturation.Min, options.ColorSaturation.Max)
}
// Bosch camera supports contrast (0-255)
if options.Contrast != nil {
if options.Contrast.Min > options.Contrast.Max {
t.Error("Expected Min <= Max for contrast")
}
t.Logf("Contrast range: %.0f - %.0f",
options.Contrast.Min, options.Contrast.Max)
}
})
t.Run("GetMoveOptions", func(t *testing.T) {
moveOptions, err := client.GetMoveOptions(ctx, videoSourceToken)
if err != nil {
t.Fatalf("GetMoveOptions failed: %v", err)
}
if moveOptions == nil {
t.Fatal("Expected move options, got nil")
}
// Log available move options
hasAbsolute := moveOptions.Absolute != nil
hasRelative := moveOptions.Relative != nil
hasContinuous := moveOptions.Continuous != nil
t.Logf("Move Options: Absolute=%v, Relative=%v, Continuous=%v",
hasAbsolute, hasRelative, hasContinuous)
})
}