add testing, reverse engineered / generated from OpenDTU source to improve compatibility testing and detecting regressions early.
This commit is contained in:
		
							parent
							
								
									6b496a39ee
								
							
						
					
					
						commit
						99959726c8
					
				
					 12 changed files with 2888 additions and 0 deletions
				
			
		
							
								
								
									
										561
									
								
								config_test.go
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										561
									
								
								config_test.go
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,561 @@
 | 
			
		|||
package main
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"os"
 | 
			
		||||
	"path/filepath"
 | 
			
		||||
	"testing"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
// TestConfigLoadFromJSONFile tests loading config from a valid JSON file
 | 
			
		||||
func TestConfigLoadFromJSONFile(t *testing.T) {
 | 
			
		||||
	// Save original environment
 | 
			
		||||
	originalConfigFile := os.Getenv("CONFIG_FILE")
 | 
			
		||||
	defer func() { os.Setenv("CONFIG_FILE", originalConfigFile) }()
 | 
			
		||||
 | 
			
		||||
	// Set CONFIG_FILE to our test data
 | 
			
		||||
	testConfigPath := filepath.Join("testdata", "test_config.json")
 | 
			
		||||
	absPath, err := filepath.Abs(testConfigPath)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		t.Fatalf("Failed to get absolute path: %v", err)
 | 
			
		||||
	}
 | 
			
		||||
	os.Setenv("CONFIG_FILE", absPath)
 | 
			
		||||
 | 
			
		||||
	// Load config from JSON file
 | 
			
		||||
	cfg, err := loadConfig()
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		t.Fatalf("loadConfig() failed: %v", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Verify all fields loaded correctly from JSON
 | 
			
		||||
	if cfg.DB != "postgres://user:password@localhost:5432/opendtu" {
 | 
			
		||||
		t.Errorf("Expected DB from JSON, got %v", cfg.DB)
 | 
			
		||||
	}
 | 
			
		||||
	if cfg.OpenDTUAddress != "192.168.1.100" {
 | 
			
		||||
		t.Errorf("Expected OpenDTUAddress from JSON, got %v", cfg.OpenDTUAddress)
 | 
			
		||||
	}
 | 
			
		||||
	if !cfg.OpenDTUAuth {
 | 
			
		||||
		t.Errorf("Expected OpenDTUAuth=true from JSON")
 | 
			
		||||
	}
 | 
			
		||||
	if cfg.OpenDTUUser != "admin" {
 | 
			
		||||
		t.Errorf("Expected OpenDTUUser from JSON, got %v", cfg.OpenDTUUser)
 | 
			
		||||
	}
 | 
			
		||||
	if cfg.OpenDTUPassword != "secret123" {
 | 
			
		||||
		t.Errorf("Expected OpenDTUPassword from JSON, got %v", cfg.OpenDTUPassword)
 | 
			
		||||
	}
 | 
			
		||||
	if !cfg.TimescaleDB {
 | 
			
		||||
		t.Errorf("Expected TimescaleDB=true from JSON")
 | 
			
		||||
	}
 | 
			
		||||
	if cfg.TZ != "Europe/Amsterdam" {
 | 
			
		||||
		t.Errorf("Expected TZ from JSON, got %v", cfg.TZ)
 | 
			
		||||
	}
 | 
			
		||||
	if cfg.LogLevel != "INFO" {
 | 
			
		||||
		t.Errorf("Expected LogLevel from JSON, got %v", cfg.LogLevel)
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// TestConfigLoadFromJSONFile_NoAuth tests loading config from JSON without auth
 | 
			
		||||
func TestConfigLoadFromJSONFile_NoAuth(t *testing.T) {
 | 
			
		||||
	// Create a temporary config file without auth
 | 
			
		||||
	tmpDir := t.TempDir()
 | 
			
		||||
	tmpFile := filepath.Join(tmpDir, "config_noauth.json")
 | 
			
		||||
 | 
			
		||||
	configContent := `{
 | 
			
		||||
		"db": "postgres://localhost/testdb",
 | 
			
		||||
		"opendtu_address": "192.168.1.200",
 | 
			
		||||
		"opendtu_auth": false,
 | 
			
		||||
		"timescaledb": false,
 | 
			
		||||
		"tz": "UTC",
 | 
			
		||||
		"log_level": "DEBUG"
 | 
			
		||||
	}`
 | 
			
		||||
 | 
			
		||||
	err := os.WriteFile(tmpFile, []byte(configContent), 0644)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		t.Fatalf("Failed to write temp config: %v", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Save original environment
 | 
			
		||||
	originalConfigFile := os.Getenv("CONFIG_FILE")
 | 
			
		||||
	defer func() { os.Setenv("CONFIG_FILE", originalConfigFile) }()
 | 
			
		||||
 | 
			
		||||
	os.Setenv("CONFIG_FILE", tmpFile)
 | 
			
		||||
 | 
			
		||||
	// Load config
 | 
			
		||||
	cfg, err := loadConfig()
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		t.Fatalf("loadConfig() failed: %v", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Verify fields
 | 
			
		||||
	if cfg.DB != "postgres://localhost/testdb" {
 | 
			
		||||
		t.Errorf("Expected DB from JSON, got %v", cfg.DB)
 | 
			
		||||
	}
 | 
			
		||||
	if cfg.OpenDTUAddress != "192.168.1.200" {
 | 
			
		||||
		t.Errorf("Expected OpenDTUAddress from JSON, got %v", cfg.OpenDTUAddress)
 | 
			
		||||
	}
 | 
			
		||||
	if cfg.OpenDTUAuth {
 | 
			
		||||
		t.Errorf("Expected OpenDTUAuth=false from JSON")
 | 
			
		||||
	}
 | 
			
		||||
	if cfg.TimescaleDB {
 | 
			
		||||
		t.Errorf("Expected TimescaleDB=false from JSON")
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// TestConfigLoadFromJSONFile_InvalidTimezone tests the timezone validation
 | 
			
		||||
func TestConfigLoadFromJSONFile_InvalidTimezone(t *testing.T) {
 | 
			
		||||
	// Create a temporary config file with invalid timezone
 | 
			
		||||
	tmpDir := t.TempDir()
 | 
			
		||||
	tmpFile := filepath.Join(tmpDir, "config_badtz.json")
 | 
			
		||||
 | 
			
		||||
	configContent := `{
 | 
			
		||||
		"db": "postgres://localhost/testdb",
 | 
			
		||||
		"opendtu_address": "192.168.1.200",
 | 
			
		||||
		"opendtu_auth": false,
 | 
			
		||||
		"tz": "Invalid/Timezone"
 | 
			
		||||
	}`
 | 
			
		||||
 | 
			
		||||
	err := os.WriteFile(tmpFile, []byte(configContent), 0644)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		t.Fatalf("Failed to write temp config: %v", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Save original environment
 | 
			
		||||
	originalConfigFile := os.Getenv("CONFIG_FILE")
 | 
			
		||||
	defer func() { os.Setenv("CONFIG_FILE", originalConfigFile) }()
 | 
			
		||||
 | 
			
		||||
	os.Setenv("CONFIG_FILE", tmpFile)
 | 
			
		||||
 | 
			
		||||
	// Load config - should not fatal, just log warning
 | 
			
		||||
	cfg, err := loadConfig()
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		t.Fatalf("loadConfig() failed: %v", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Should still load other fields successfully
 | 
			
		||||
	if cfg.DB != "postgres://localhost/testdb" {
 | 
			
		||||
		t.Errorf("Expected DB to load despite invalid timezone")
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// TestConfigLoadFromEnv_WithTimescaleDB tests env var path with TimescaleDB enabled
 | 
			
		||||
func TestConfigLoadFromEnv_WithTimescaleDB(t *testing.T) {
 | 
			
		||||
	// Save original environment
 | 
			
		||||
	originalVars := map[string]string{
 | 
			
		||||
		"CONFIG_FILE":         os.Getenv("CONFIG_FILE"),
 | 
			
		||||
		"DB_URL":              os.Getenv("DB_URL"),
 | 
			
		||||
		"OPENDTU_ADDRESS":     os.Getenv("OPENDTU_ADDRESS"),
 | 
			
		||||
		"OPENDTU_AUTH":        os.Getenv("OPENDTU_AUTH"),
 | 
			
		||||
		"TIMESCALEDB_ENABLED": os.Getenv("TIMESCALEDB_ENABLED"),
 | 
			
		||||
		"TZ":                  os.Getenv("TZ"),
 | 
			
		||||
		"LOG_LEVEL":           os.Getenv("LOG_LEVEL"),
 | 
			
		||||
	}
 | 
			
		||||
	defer func() {
 | 
			
		||||
		for k, v := range originalVars {
 | 
			
		||||
			os.Setenv(k, v)
 | 
			
		||||
		}
 | 
			
		||||
	}()
 | 
			
		||||
 | 
			
		||||
	// Set environment for non-existent config file
 | 
			
		||||
	os.Setenv("CONFIG_FILE", "/nonexistent/config.json")
 | 
			
		||||
	os.Setenv("DB_URL", "postgres://testhost/testdb")
 | 
			
		||||
	os.Setenv("OPENDTU_ADDRESS", "10.0.0.1")
 | 
			
		||||
	os.Setenv("OPENDTU_AUTH", "false")
 | 
			
		||||
	os.Setenv("TIMESCALEDB_ENABLED", "true")
 | 
			
		||||
	os.Setenv("TZ", "America/New_York")
 | 
			
		||||
	os.Setenv("LOG_LEVEL", "WARN")
 | 
			
		||||
 | 
			
		||||
	cfg, err := loadConfig()
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		t.Fatalf("loadConfig() failed: %v", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if cfg.DB != "postgres://testhost/testdb" {
 | 
			
		||||
		t.Errorf("Expected DB from env, got %v", cfg.DB)
 | 
			
		||||
	}
 | 
			
		||||
	if !cfg.TimescaleDB {
 | 
			
		||||
		t.Errorf("Expected TimescaleDB=true from env")
 | 
			
		||||
	}
 | 
			
		||||
	if cfg.TZ != "America/New_York" {
 | 
			
		||||
		t.Errorf("Expected TZ from env, got %v", cfg.TZ)
 | 
			
		||||
	}
 | 
			
		||||
	if cfg.LogLevel != "WARN" {
 | 
			
		||||
		t.Errorf("Expected LogLevel from env, got %v", cfg.LogLevel)
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// TestConfigLoadFromEnv_WithAuth tests env var path with auth enabled
 | 
			
		||||
func TestConfigLoadFromEnv_WithAuth(t *testing.T) {
 | 
			
		||||
	// Save original environment
 | 
			
		||||
	originalVars := map[string]string{
 | 
			
		||||
		"CONFIG_FILE":      os.Getenv("CONFIG_FILE"),
 | 
			
		||||
		"DB_URL":           os.Getenv("DB_URL"),
 | 
			
		||||
		"OPENDTU_ADDRESS":  os.Getenv("OPENDTU_ADDRESS"),
 | 
			
		||||
		"OPENDTU_AUTH":     os.Getenv("OPENDTU_AUTH"),
 | 
			
		||||
		"OPENDTU_USERNAME": os.Getenv("OPENDTU_USERNAME"),
 | 
			
		||||
		"OPENDTU_PASSWORD": os.Getenv("OPENDTU_PASSWORD"),
 | 
			
		||||
	}
 | 
			
		||||
	defer func() {
 | 
			
		||||
		for k, v := range originalVars {
 | 
			
		||||
			os.Setenv(k, v)
 | 
			
		||||
		}
 | 
			
		||||
	}()
 | 
			
		||||
 | 
			
		||||
	// Set environment with auth enabled
 | 
			
		||||
	os.Setenv("CONFIG_FILE", "/nonexistent/config.json")
 | 
			
		||||
	os.Setenv("DB_URL", "postgres://testhost/testdb")
 | 
			
		||||
	os.Setenv("OPENDTU_ADDRESS", "10.0.0.1")
 | 
			
		||||
	os.Setenv("OPENDTU_AUTH", "true")
 | 
			
		||||
	os.Setenv("OPENDTU_USERNAME", "testuser")
 | 
			
		||||
	os.Setenv("OPENDTU_PASSWORD", "testpass")
 | 
			
		||||
 | 
			
		||||
	cfg, err := loadConfig()
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		t.Fatalf("loadConfig() failed: %v", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if !cfg.OpenDTUAuth {
 | 
			
		||||
		t.Errorf("Expected OpenDTUAuth=true from env")
 | 
			
		||||
	}
 | 
			
		||||
	if cfg.OpenDTUUser != "testuser" {
 | 
			
		||||
		t.Errorf("Expected OpenDTUUser from env, got %v", cfg.OpenDTUUser)
 | 
			
		||||
	}
 | 
			
		||||
	if cfg.OpenDTUPassword != "testpass" {
 | 
			
		||||
		t.Errorf("Expected OpenDTUPassword from env, got %v", cfg.OpenDTUPassword)
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// TestConfigLoadFromJSONFile_InvalidJSON tests error when JSON is malformed
 | 
			
		||||
func TestConfigLoadFromJSONFile_InvalidJSON(t *testing.T) {
 | 
			
		||||
	tmpDir := t.TempDir()
 | 
			
		||||
	tmpFile := filepath.Join(tmpDir, "invalid.json")
 | 
			
		||||
 | 
			
		||||
	// Write invalid JSON
 | 
			
		||||
	err := os.WriteFile(tmpFile, []byte("{invalid json}"), 0644)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		t.Fatalf("Failed to write temp config: %v", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	originalConfigFile := os.Getenv("CONFIG_FILE")
 | 
			
		||||
	defer func() { os.Setenv("CONFIG_FILE", originalConfigFile) }()
 | 
			
		||||
	os.Setenv("CONFIG_FILE", tmpFile)
 | 
			
		||||
 | 
			
		||||
	_, err = loadConfig()
 | 
			
		||||
	if err == nil {
 | 
			
		||||
		t.Error("Expected error for invalid JSON, got nil")
 | 
			
		||||
	}
 | 
			
		||||
	if err != nil && !contains(err.Error(), "error parsing config file") {
 | 
			
		||||
		t.Errorf("Expected 'error parsing config file' error, got: %v", err)
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// TestConfigLoadFromJSONFile_MissingDB tests error when DB is not set in JSON
 | 
			
		||||
func TestConfigLoadFromJSONFile_MissingDB(t *testing.T) {
 | 
			
		||||
	// Reset global config to avoid test pollution
 | 
			
		||||
	config = Config{}
 | 
			
		||||
 | 
			
		||||
	tmpDir := t.TempDir()
 | 
			
		||||
	tmpFile := filepath.Join(tmpDir, "no_db.json")
 | 
			
		||||
 | 
			
		||||
	configContent := `{
 | 
			
		||||
		"opendtu_address": "192.168.1.100"
 | 
			
		||||
	}`
 | 
			
		||||
 | 
			
		||||
	err := os.WriteFile(tmpFile, []byte(configContent), 0644)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		t.Fatalf("Failed to write temp config: %v", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	originalConfigFile := os.Getenv("CONFIG_FILE")
 | 
			
		||||
	defer func() { os.Setenv("CONFIG_FILE", originalConfigFile) }()
 | 
			
		||||
	os.Setenv("CONFIG_FILE", tmpFile)
 | 
			
		||||
 | 
			
		||||
	_, err = loadConfig()
 | 
			
		||||
	if err == nil {
 | 
			
		||||
		t.Error("Expected error for missing DB, got nil")
 | 
			
		||||
	}
 | 
			
		||||
	if err != nil && !contains(err.Error(), "db connection settings are not set") {
 | 
			
		||||
		t.Errorf("Expected 'db connection settings' error, got: %v", err)
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// TestConfigLoadFromJSONFile_MissingOpenDTUAddress tests error when opendtu_address is not set
 | 
			
		||||
func TestConfigLoadFromJSONFile_MissingOpenDTUAddress(t *testing.T) {
 | 
			
		||||
	// Reset global config to avoid test pollution
 | 
			
		||||
	config = Config{}
 | 
			
		||||
 | 
			
		||||
	tmpDir := t.TempDir()
 | 
			
		||||
	tmpFile := filepath.Join(tmpDir, "no_address.json")
 | 
			
		||||
 | 
			
		||||
	configContent := `{
 | 
			
		||||
		"db": "postgres://localhost/testdb"
 | 
			
		||||
	}`
 | 
			
		||||
 | 
			
		||||
	err := os.WriteFile(tmpFile, []byte(configContent), 0644)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		t.Fatalf("Failed to write temp config: %v", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	originalConfigFile := os.Getenv("CONFIG_FILE")
 | 
			
		||||
	defer func() { os.Setenv("CONFIG_FILE", originalConfigFile) }()
 | 
			
		||||
	os.Setenv("CONFIG_FILE", tmpFile)
 | 
			
		||||
 | 
			
		||||
	_, err = loadConfig()
 | 
			
		||||
	if err == nil {
 | 
			
		||||
		t.Error("Expected error for missing OpenDTU address, got nil")
 | 
			
		||||
	}
 | 
			
		||||
	if err != nil && !contains(err.Error(), "opendtu_address is not set") {
 | 
			
		||||
		t.Errorf("Expected 'opendtu_address is not set' error, got: %v", err)
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// TestConfigLoadFromJSONFile_MissingUsername tests error when username is missing with auth enabled
 | 
			
		||||
func TestConfigLoadFromJSONFile_MissingUsername(t *testing.T) {
 | 
			
		||||
	// Reset global config to avoid test pollution
 | 
			
		||||
	config = Config{}
 | 
			
		||||
 | 
			
		||||
	tmpDir := t.TempDir()
 | 
			
		||||
	tmpFile := filepath.Join(tmpDir, "no_username.json")
 | 
			
		||||
 | 
			
		||||
	configContent := `{
 | 
			
		||||
		"db": "postgres://localhost/testdb",
 | 
			
		||||
		"opendtu_address": "192.168.1.100",
 | 
			
		||||
		"opendtu_auth": true,
 | 
			
		||||
		"opendtu_password": "secret"
 | 
			
		||||
	}`
 | 
			
		||||
 | 
			
		||||
	err := os.WriteFile(tmpFile, []byte(configContent), 0644)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		t.Fatalf("Failed to write temp config: %v", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	originalConfigFile := os.Getenv("CONFIG_FILE")
 | 
			
		||||
	defer func() { os.Setenv("CONFIG_FILE", originalConfigFile) }()
 | 
			
		||||
	os.Setenv("CONFIG_FILE", tmpFile)
 | 
			
		||||
 | 
			
		||||
	_, err = loadConfig()
 | 
			
		||||
	if err == nil {
 | 
			
		||||
		t.Error("Expected error for missing username, got nil")
 | 
			
		||||
	}
 | 
			
		||||
	if err != nil && !contains(err.Error(), "opendtu_username is not set") {
 | 
			
		||||
		t.Errorf("Expected 'opendtu_username is not set' error, got: %v", err)
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// TestConfigLoadFromJSONFile_MissingPassword tests error when password is missing with auth enabled
 | 
			
		||||
func TestConfigLoadFromJSONFile_MissingPassword(t *testing.T) {
 | 
			
		||||
	// Reset global config to avoid test pollution
 | 
			
		||||
	config = Config{}
 | 
			
		||||
 | 
			
		||||
	tmpDir := t.TempDir()
 | 
			
		||||
	tmpFile := filepath.Join(tmpDir, "no_password.json")
 | 
			
		||||
 | 
			
		||||
	configContent := `{
 | 
			
		||||
		"db": "postgres://localhost/testdb",
 | 
			
		||||
		"opendtu_address": "192.168.1.100",
 | 
			
		||||
		"opendtu_auth": true,
 | 
			
		||||
		"opendtu_username": "admin"
 | 
			
		||||
	}`
 | 
			
		||||
 | 
			
		||||
	err := os.WriteFile(tmpFile, []byte(configContent), 0644)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		t.Fatalf("Failed to write temp config: %v", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	originalConfigFile := os.Getenv("CONFIG_FILE")
 | 
			
		||||
	defer func() { os.Setenv("CONFIG_FILE", originalConfigFile) }()
 | 
			
		||||
	os.Setenv("CONFIG_FILE", tmpFile)
 | 
			
		||||
 | 
			
		||||
	_, err = loadConfig()
 | 
			
		||||
	if err == nil {
 | 
			
		||||
		t.Error("Expected error for missing password, got nil")
 | 
			
		||||
	}
 | 
			
		||||
	if err != nil && !contains(err.Error(), "opendtu_password is not set") {
 | 
			
		||||
		t.Errorf("Expected 'opendtu_password is not set' error, got: %v", err)
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// TestConfigLoadFromEnv_MissingDBURL tests error when DB_URL env var is missing
 | 
			
		||||
func TestConfigLoadFromEnv_MissingDBURL(t *testing.T) {
 | 
			
		||||
	originalVars := map[string]string{
 | 
			
		||||
		"CONFIG_FILE":     os.Getenv("CONFIG_FILE"),
 | 
			
		||||
		"DB_URL":          os.Getenv("DB_URL"),
 | 
			
		||||
		"OPENDTU_ADDRESS": os.Getenv("OPENDTU_ADDRESS"),
 | 
			
		||||
	}
 | 
			
		||||
	defer func() {
 | 
			
		||||
		for k, v := range originalVars {
 | 
			
		||||
			os.Setenv(k, v)
 | 
			
		||||
		}
 | 
			
		||||
	}()
 | 
			
		||||
 | 
			
		||||
	os.Setenv("CONFIG_FILE", "/nonexistent/config.json")
 | 
			
		||||
	os.Unsetenv("DB_URL")
 | 
			
		||||
	os.Setenv("OPENDTU_ADDRESS", "192.168.1.100")
 | 
			
		||||
 | 
			
		||||
	_, err := loadConfig()
 | 
			
		||||
	if err == nil {
 | 
			
		||||
		t.Error("Expected error for missing DB_URL, got nil")
 | 
			
		||||
	}
 | 
			
		||||
	if err != nil && !contains(err.Error(), "DB_URL environment variable is not set") {
 | 
			
		||||
		t.Errorf("Expected 'DB_URL environment variable is not set' error, got: %v", err)
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// TestConfigLoadFromEnv_MissingOpenDTUAddress tests error when OPENDTU_ADDRESS is missing
 | 
			
		||||
func TestConfigLoadFromEnv_MissingOpenDTUAddress(t *testing.T) {
 | 
			
		||||
	originalVars := map[string]string{
 | 
			
		||||
		"CONFIG_FILE":     os.Getenv("CONFIG_FILE"),
 | 
			
		||||
		"DB_URL":          os.Getenv("DB_URL"),
 | 
			
		||||
		"OPENDTU_ADDRESS": os.Getenv("OPENDTU_ADDRESS"),
 | 
			
		||||
	}
 | 
			
		||||
	defer func() {
 | 
			
		||||
		for k, v := range originalVars {
 | 
			
		||||
			os.Setenv(k, v)
 | 
			
		||||
		}
 | 
			
		||||
	}()
 | 
			
		||||
 | 
			
		||||
	os.Setenv("CONFIG_FILE", "/nonexistent/config.json")
 | 
			
		||||
	os.Setenv("DB_URL", "postgres://localhost/testdb")
 | 
			
		||||
	os.Unsetenv("OPENDTU_ADDRESS")
 | 
			
		||||
 | 
			
		||||
	_, err := loadConfig()
 | 
			
		||||
	if err == nil {
 | 
			
		||||
		t.Error("Expected error for missing OPENDTU_ADDRESS, got nil")
 | 
			
		||||
	}
 | 
			
		||||
	if err != nil && !contains(err.Error(), "OPENDTU_ADDRESS environment variable is not set") {
 | 
			
		||||
		t.Errorf("Expected 'OPENDTU_ADDRESS environment variable is not set' error, got: %v", err)
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// TestConfigLoadFromEnv_InvalidAuthBool tests error when OPENDTU_AUTH has invalid boolean
 | 
			
		||||
func TestConfigLoadFromEnv_InvalidAuthBool(t *testing.T) {
 | 
			
		||||
	originalVars := map[string]string{
 | 
			
		||||
		"CONFIG_FILE":     os.Getenv("CONFIG_FILE"),
 | 
			
		||||
		"DB_URL":          os.Getenv("DB_URL"),
 | 
			
		||||
		"OPENDTU_ADDRESS": os.Getenv("OPENDTU_ADDRESS"),
 | 
			
		||||
		"OPENDTU_AUTH":    os.Getenv("OPENDTU_AUTH"),
 | 
			
		||||
	}
 | 
			
		||||
	defer func() {
 | 
			
		||||
		for k, v := range originalVars {
 | 
			
		||||
			os.Setenv(k, v)
 | 
			
		||||
		}
 | 
			
		||||
	}()
 | 
			
		||||
 | 
			
		||||
	os.Setenv("CONFIG_FILE", "/nonexistent/config.json")
 | 
			
		||||
	os.Setenv("DB_URL", "postgres://localhost/testdb")
 | 
			
		||||
	os.Setenv("OPENDTU_ADDRESS", "192.168.1.100")
 | 
			
		||||
	os.Setenv("OPENDTU_AUTH", "invalid")
 | 
			
		||||
 | 
			
		||||
	_, err := loadConfig()
 | 
			
		||||
	if err == nil {
 | 
			
		||||
		t.Error("Expected error for invalid OPENDTU_AUTH, got nil")
 | 
			
		||||
	}
 | 
			
		||||
	if err != nil && !contains(err.Error(), "error parsing OPENDTU_AUTH") {
 | 
			
		||||
		t.Errorf("Expected 'error parsing OPENDTU_AUTH' error, got: %v", err)
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// TestConfigLoadFromEnv_MissingUsername tests error when OPENDTU_USERNAME is missing with auth
 | 
			
		||||
func TestConfigLoadFromEnv_MissingUsername(t *testing.T) {
 | 
			
		||||
	originalVars := map[string]string{
 | 
			
		||||
		"CONFIG_FILE":      os.Getenv("CONFIG_FILE"),
 | 
			
		||||
		"DB_URL":           os.Getenv("DB_URL"),
 | 
			
		||||
		"OPENDTU_ADDRESS":  os.Getenv("OPENDTU_ADDRESS"),
 | 
			
		||||
		"OPENDTU_AUTH":     os.Getenv("OPENDTU_AUTH"),
 | 
			
		||||
		"OPENDTU_USERNAME": os.Getenv("OPENDTU_USERNAME"),
 | 
			
		||||
	}
 | 
			
		||||
	defer func() {
 | 
			
		||||
		for k, v := range originalVars {
 | 
			
		||||
			os.Setenv(k, v)
 | 
			
		||||
		}
 | 
			
		||||
	}()
 | 
			
		||||
 | 
			
		||||
	os.Setenv("CONFIG_FILE", "/nonexistent/config.json")
 | 
			
		||||
	os.Setenv("DB_URL", "postgres://localhost/testdb")
 | 
			
		||||
	os.Setenv("OPENDTU_ADDRESS", "192.168.1.100")
 | 
			
		||||
	os.Setenv("OPENDTU_AUTH", "true")
 | 
			
		||||
	os.Unsetenv("OPENDTU_USERNAME")
 | 
			
		||||
	os.Setenv("OPENDTU_PASSWORD", "secret")
 | 
			
		||||
 | 
			
		||||
	_, err := loadConfig()
 | 
			
		||||
	if err == nil {
 | 
			
		||||
		t.Error("Expected error for missing OPENDTU_USERNAME, got nil")
 | 
			
		||||
	}
 | 
			
		||||
	if err != nil && !contains(err.Error(), "OPENDTU_USERNAME environment variable is not set") {
 | 
			
		||||
		t.Errorf("Expected 'OPENDTU_USERNAME environment variable is not set' error, got: %v", err)
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// TestConfigLoadFromEnv_MissingPassword tests error when OPENDTU_PASSWORD is missing with auth
 | 
			
		||||
func TestConfigLoadFromEnv_MissingPassword(t *testing.T) {
 | 
			
		||||
	originalVars := map[string]string{
 | 
			
		||||
		"CONFIG_FILE":      os.Getenv("CONFIG_FILE"),
 | 
			
		||||
		"DB_URL":           os.Getenv("DB_URL"),
 | 
			
		||||
		"OPENDTU_ADDRESS":  os.Getenv("OPENDTU_ADDRESS"),
 | 
			
		||||
		"OPENDTU_AUTH":     os.Getenv("OPENDTU_AUTH"),
 | 
			
		||||
		"OPENDTU_USERNAME": os.Getenv("OPENDTU_USERNAME"),
 | 
			
		||||
		"OPENDTU_PASSWORD": os.Getenv("OPENDTU_PASSWORD"),
 | 
			
		||||
	}
 | 
			
		||||
	defer func() {
 | 
			
		||||
		for k, v := range originalVars {
 | 
			
		||||
			os.Setenv(k, v)
 | 
			
		||||
		}
 | 
			
		||||
	}()
 | 
			
		||||
 | 
			
		||||
	os.Setenv("CONFIG_FILE", "/nonexistent/config.json")
 | 
			
		||||
	os.Setenv("DB_URL", "postgres://localhost/testdb")
 | 
			
		||||
	os.Setenv("OPENDTU_ADDRESS", "192.168.1.100")
 | 
			
		||||
	os.Setenv("OPENDTU_AUTH", "true")
 | 
			
		||||
	os.Setenv("OPENDTU_USERNAME", "admin")
 | 
			
		||||
	os.Unsetenv("OPENDTU_PASSWORD")
 | 
			
		||||
 | 
			
		||||
	_, err := loadConfig()
 | 
			
		||||
	if err == nil {
 | 
			
		||||
		t.Error("Expected error for missing OPENDTU_PASSWORD, got nil")
 | 
			
		||||
	}
 | 
			
		||||
	if err != nil && !contains(err.Error(), "OPENDTU_PASSWORD environment variable is not set") {
 | 
			
		||||
		t.Errorf("Expected 'OPENDTU_PASSWORD environment variable is not set' error, got: %v", err)
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// TestConfigLoadFromEnv_InvalidTimescaleDBBool tests error when TIMESCALEDB_ENABLED has invalid boolean
 | 
			
		||||
func TestConfigLoadFromEnv_InvalidTimescaleDBBool(t *testing.T) {
 | 
			
		||||
	originalVars := map[string]string{
 | 
			
		||||
		"CONFIG_FILE":         os.Getenv("CONFIG_FILE"),
 | 
			
		||||
		"DB_URL":              os.Getenv("DB_URL"),
 | 
			
		||||
		"OPENDTU_ADDRESS":     os.Getenv("OPENDTU_ADDRESS"),
 | 
			
		||||
		"OPENDTU_AUTH":        os.Getenv("OPENDTU_AUTH"),
 | 
			
		||||
		"TIMESCALEDB_ENABLED": os.Getenv("TIMESCALEDB_ENABLED"),
 | 
			
		||||
	}
 | 
			
		||||
	defer func() {
 | 
			
		||||
		for k, v := range originalVars {
 | 
			
		||||
			os.Setenv(k, v)
 | 
			
		||||
		}
 | 
			
		||||
	}()
 | 
			
		||||
 | 
			
		||||
	os.Setenv("CONFIG_FILE", "/nonexistent/config.json")
 | 
			
		||||
	os.Setenv("DB_URL", "postgres://localhost/testdb")
 | 
			
		||||
	os.Setenv("OPENDTU_ADDRESS", "192.168.1.100")
 | 
			
		||||
	os.Setenv("OPENDTU_AUTH", "false") // Set auth to false so we don't need username/password
 | 
			
		||||
	os.Setenv("TIMESCALEDB_ENABLED", "not-a-bool")
 | 
			
		||||
 | 
			
		||||
	_, err := loadConfig()
 | 
			
		||||
	if err == nil {
 | 
			
		||||
		t.Error("Expected error for invalid TIMESCALEDB_ENABLED, got nil")
 | 
			
		||||
	}
 | 
			
		||||
	if err != nil && !contains(err.Error(), "error parsing TIMESCALEDB_ENABLED") {
 | 
			
		||||
		t.Errorf("Expected 'error parsing TIMESCALEDB_ENABLED' error, got: %v", err)
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Helper function to check if a string contains a substring
 | 
			
		||||
func contains(s, substr string) bool {
 | 
			
		||||
	return len(s) >= len(substr) && (s == substr || len(s) > len(substr) &&
 | 
			
		||||
		(s[:len(substr)] == substr || s[len(s)-len(substr):] == substr ||
 | 
			
		||||
			func() bool {
 | 
			
		||||
				for i := 0; i <= len(s)-len(substr); i++ {
 | 
			
		||||
					if s[i:i+len(substr)] == substr {
 | 
			
		||||
						return true
 | 
			
		||||
					}
 | 
			
		||||
				}
 | 
			
		||||
				return false
 | 
			
		||||
			}()))
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										1
									
								
								go.mod
									
										
									
									
									
								
							
							
						
						
									
										1
									
								
								go.mod
									
										
									
									
									
								
							| 
						 | 
				
			
			@ -9,6 +9,7 @@ require (
 | 
			
		|||
)
 | 
			
		||||
 | 
			
		||||
require (
 | 
			
		||||
	github.com/DATA-DOG/go-sqlmock v1.5.2 // indirect
 | 
			
		||||
	github.com/mfridman/interpolate v0.0.2 // indirect
 | 
			
		||||
	github.com/sethvargo/go-retry v0.3.0 // indirect
 | 
			
		||||
	go.uber.org/multierr v1.11.0 // indirect
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										3
									
								
								go.sum
									
										
									
									
									
								
							
							
						
						
									
										3
									
								
								go.sum
									
										
									
									
									
								
							| 
						 | 
				
			
			@ -1,3 +1,5 @@
 | 
			
		|||
github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
 | 
			
		||||
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
 | 
			
		||||
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/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
 | 
			
		||||
| 
						 | 
				
			
			@ -6,6 +8,7 @@ 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=
 | 
			
		||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
 | 
			
		||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
 | 
			
		||||
github.com/kisielk/sqlstruct v0.0.0-20201105191214-5f3e10d3ab46/go.mod h1:yyMNCyc/Ib3bDTKd379tNMpB/7/H5TjM2Y9QJ5THLbE=
 | 
			
		||||
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
 | 
			
		||||
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
 | 
			
		||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										466
									
								
								integration_test.go
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										466
									
								
								integration_test.go
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,466 @@
 | 
			
		|||
package main
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"encoding/json"
 | 
			
		||||
	"fmt"
 | 
			
		||||
	"net/http"
 | 
			
		||||
	"net/http/httptest"
 | 
			
		||||
	"os"
 | 
			
		||||
	"path/filepath"
 | 
			
		||||
	"testing"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
func loadTestData(filename string) ([]byte, error) {
 | 
			
		||||
	return os.ReadFile(filepath.Join("testdata", filename))
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func TestLiveDataFromFile(t *testing.T) {
 | 
			
		||||
	// Test producing data
 | 
			
		||||
	data, err := loadTestData("livedata_producing.json")
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		t.Fatalf("Failed to load test data: %v", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	var liveData LiveData
 | 
			
		||||
	err = json.Unmarshal(data, &liveData)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		t.Fatalf("Failed to unmarshal LiveData: %v", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Validate the structure
 | 
			
		||||
	if len(liveData.Inverters) != 2 {
 | 
			
		||||
		t.Errorf("Expected 2 inverters, got %v", len(liveData.Inverters))
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	inverter := liveData.Inverters[0]
 | 
			
		||||
	if inverter.Serial != "114173123456" {
 | 
			
		||||
		t.Errorf("Expected first inverter serial 114173123456, got %v", inverter.Serial)
 | 
			
		||||
	}
 | 
			
		||||
	if !inverter.Producing {
 | 
			
		||||
		t.Errorf("Expected first inverter to be producing")
 | 
			
		||||
	}
 | 
			
		||||
	if !inverter.Reachable {
 | 
			
		||||
		t.Errorf("Expected first inverter to be reachable")
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Test new OpenDTU fields
 | 
			
		||||
	if inverter.Order != 0 {
 | 
			
		||||
		t.Errorf("Expected first inverter Order=0, got %v", inverter.Order)
 | 
			
		||||
	}
 | 
			
		||||
	if inverter.DataAge != 0 {
 | 
			
		||||
		t.Errorf("Expected first inverter DataAge=0, got %v", inverter.DataAge)
 | 
			
		||||
	}
 | 
			
		||||
	if inverter.DataAgeMs != 124 {
 | 
			
		||||
		t.Errorf("Expected first inverter DataAgeMs=124, got %v", inverter.DataAgeMs)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Test radio_stats from OpenDTU
 | 
			
		||||
	if inverter.RadioStats.TxRequest != 12345 {
 | 
			
		||||
		t.Errorf("Expected RadioStats.TxRequest=12345, got %v", inverter.RadioStats.TxRequest)
 | 
			
		||||
	}
 | 
			
		||||
	if inverter.RadioStats.RxSuccess != 12000 {
 | 
			
		||||
		t.Errorf("Expected RadioStats.RxSuccess=12000, got %v", inverter.RadioStats.RxSuccess)
 | 
			
		||||
	}
 | 
			
		||||
	if inverter.RadioStats.RSSI != -65.5 {
 | 
			
		||||
		t.Errorf("Expected RadioStats.RSSI=-65.5, got %v", inverter.RadioStats.RSSI)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Test second inverter has correct order
 | 
			
		||||
	if liveData.Inverters[1].Order != 1 {
 | 
			
		||||
		t.Errorf("Expected second inverter Order=1, got %v", liveData.Inverters[1].Order)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Test Hints with pin_mapping_issue
 | 
			
		||||
	if !liveData.Hints.TimeSync {
 | 
			
		||||
		t.Errorf("Expected Hints.TimeSync=true, got %v", liveData.Hints.TimeSync)
 | 
			
		||||
	}
 | 
			
		||||
	if liveData.Hints.RadioProblem {
 | 
			
		||||
		t.Errorf("Expected Hints.RadioProblem=false, got %v", liveData.Hints.RadioProblem)
 | 
			
		||||
	}
 | 
			
		||||
	if liveData.Hints.PinMappingIssue {
 | 
			
		||||
		t.Errorf("Expected Hints.PinMappingIssue=false, got %v", liveData.Hints.PinMappingIssue)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Check total power
 | 
			
		||||
	if liveData.Total.Power.V != 1276.9 {
 | 
			
		||||
		t.Errorf("Expected total power 1276.9W, got %v", liveData.Total.Power.V)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Test night data
 | 
			
		||||
	data, err = loadTestData("livedata_night.json")
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		t.Fatalf("Failed to load night test data: %v", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	err = json.Unmarshal(data, &liveData)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		t.Fatalf("Failed to unmarshal night LiveData: %v", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if len(liveData.Inverters) != 1 {
 | 
			
		||||
		t.Errorf("Expected 1 inverter in night data, got %v", len(liveData.Inverters))
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	inverter = liveData.Inverters[0]
 | 
			
		||||
	if inverter.Producing {
 | 
			
		||||
		t.Errorf("Expected inverter not to be producing at night")
 | 
			
		||||
	}
 | 
			
		||||
	if liveData.Total.Power.V != 0.0 {
 | 
			
		||||
		t.Errorf("Expected total power 0W at night, got %v", liveData.Total.Power.V)
 | 
			
		||||
	}
 | 
			
		||||
	if liveData.Total.YieldTotal.V != 16253.99 {
 | 
			
		||||
		t.Errorf("Expected total YieldTotal 16253.99kWh, got %v", liveData.Total.YieldTotal.V)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Test that DataAge is old at night (no recent data)
 | 
			
		||||
	if inverter.DataAge != 20840 {
 | 
			
		||||
		t.Errorf("Expected DataAge=20840 (old data at night), got %v", inverter.DataAge)
 | 
			
		||||
	}
 | 
			
		||||
	if inverter.DataAgeMs != 20840477 {
 | 
			
		||||
		t.Errorf("Expected DataAgeMs=20840477, got %v", inverter.DataAgeMs)
 | 
			
		||||
	}
 | 
			
		||||
	if inverter.LimitAbsolute != 2250 {
 | 
			
		||||
		t.Errorf("Expected LimitAbsolute=2250, got %v", inverter.LimitAbsolute)
 | 
			
		||||
	}
 | 
			
		||||
	if inverter.PollEnabled {
 | 
			
		||||
		t.Errorf("Expected PollEnabled=false at night")
 | 
			
		||||
	}
 | 
			
		||||
	if inverter.Reachable {
 | 
			
		||||
		t.Errorf("Expected Reachable=false at night")
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if len(inverter.DC) != 6 {
 | 
			
		||||
		t.Errorf("Expected 6 DC strings, got %d", len(inverter.DC))
 | 
			
		||||
	}
 | 
			
		||||
	if inverter.DC["5"].Irradiation.Max != 440 {
 | 
			
		||||
		t.Errorf("Expected Irradiation max 440 for string 5, got %d", inverter.DC["5"].Irradiation.Max)
 | 
			
		||||
	}
 | 
			
		||||
	if inverter.RadioStats.TxRequest != 147 {
 | 
			
		||||
		t.Errorf("Expected TxRequest=147, got %v", inverter.RadioStats.TxRequest)
 | 
			
		||||
	}
 | 
			
		||||
	if inverter.RadioStats.RxFailNothing != 147 {
 | 
			
		||||
		t.Errorf("Expected RxFailNothing=147, got %v", inverter.RadioStats.RxFailNothing)
 | 
			
		||||
	}
 | 
			
		||||
	if inverter.RadioStats.RSSI != -62 {
 | 
			
		||||
		t.Errorf("Expected RSSI=-62, got %v", inverter.RadioStats.RSSI)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Test warning data with limit fallback and hints
 | 
			
		||||
	data, err = loadTestData("livedata_warnings.json")
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		t.Fatalf("Failed to load warning test data: %v", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	err = json.Unmarshal(data, &liveData)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		t.Fatalf("Failed to unmarshal warning LiveData: %v", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if len(liveData.Inverters) != 1 {
 | 
			
		||||
		t.Fatalf("Expected 1 inverter in warning data, got %d", len(liveData.Inverters))
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	inverter = liveData.Inverters[0]
 | 
			
		||||
	if inverter.LimitAbsolute != -1 {
 | 
			
		||||
		t.Errorf("Expected LimitAbsolute=-1 fallback, got %v", inverter.LimitAbsolute)
 | 
			
		||||
	}
 | 
			
		||||
	if inverter.Events != -1 {
 | 
			
		||||
		t.Errorf("Expected Events=-1 when event log missing, got %v", inverter.Events)
 | 
			
		||||
	}
 | 
			
		||||
	if !inverter.PollEnabled {
 | 
			
		||||
		t.Errorf("Expected PollEnabled=true in warning data")
 | 
			
		||||
	}
 | 
			
		||||
	if inverter.Reachable {
 | 
			
		||||
		t.Errorf("Expected Reachable=false in warning data")
 | 
			
		||||
	}
 | 
			
		||||
	if inverter.RadioStats.RxFailNothing != 12 {
 | 
			
		||||
		t.Errorf("Expected RxFailNothing=12, got %v", inverter.RadioStats.RxFailNothing)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if liveData.Hints.TimeSync {
 | 
			
		||||
		t.Errorf("Expected Hints.TimeSync=false, got %v", liveData.Hints.TimeSync)
 | 
			
		||||
	}
 | 
			
		||||
	if !liveData.Hints.RadioProblem {
 | 
			
		||||
		t.Errorf("Expected Hints.RadioProblem=true, got %v", liveData.Hints.RadioProblem)
 | 
			
		||||
	}
 | 
			
		||||
	if !liveData.Hints.DefaultPassword {
 | 
			
		||||
		t.Errorf("Expected Hints.DefaultPassword=true, got %v", liveData.Hints.DefaultPassword)
 | 
			
		||||
	}
 | 
			
		||||
	if !liveData.Hints.PinMappingIssue {
 | 
			
		||||
		t.Errorf("Expected Hints.PinMappingIssue=true, got %v", liveData.Hints.PinMappingIssue)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	var generic map[string]interface{}
 | 
			
		||||
	if err := json.Unmarshal(data, &generic); err != nil {
 | 
			
		||||
		t.Fatalf("Failed to unmarshal warning fixture generically: %v", err)
 | 
			
		||||
	}
 | 
			
		||||
	invertersValue, ok := generic["inverters"].([]interface{})
 | 
			
		||||
	if !ok || len(invertersValue) == 0 {
 | 
			
		||||
		t.Fatalf("Generic inverter array missing")
 | 
			
		||||
	}
 | 
			
		||||
	invMap, ok := invertersValue[0].(map[string]interface{})
 | 
			
		||||
	if !ok {
 | 
			
		||||
		t.Fatalf("Generic inverter object missing")
 | 
			
		||||
	}
 | 
			
		||||
	if _, exists := invMap["BAT"]; !exists {
 | 
			
		||||
		t.Errorf("Expected additional channel map 'BAT' to be present in fixture")
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func TestEventsResponseFromFile(t *testing.T) {
 | 
			
		||||
	data, err := loadTestData("events_response.json")
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		t.Fatalf("Failed to load events test data: %v", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	var eventsResponse EventsResponse
 | 
			
		||||
	err = json.Unmarshal(data, &eventsResponse)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		t.Fatalf("Failed to unmarshal EventsResponse: %v", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if eventsResponse.Count != 3 {
 | 
			
		||||
		t.Errorf("Expected 3 events, got %v", eventsResponse.Count)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if len(eventsResponse.Events) != 3 {
 | 
			
		||||
		t.Errorf("Expected 3 events in array, got %v", len(eventsResponse.Events))
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Check first event
 | 
			
		||||
	firstEvent := eventsResponse.Events[0]
 | 
			
		||||
	if firstEvent.MessageID != 1 {
 | 
			
		||||
		t.Errorf("Expected first event MessageID 1, got %v", firstEvent.MessageID)
 | 
			
		||||
	}
 | 
			
		||||
	if firstEvent.Message != "Inverter start" {
 | 
			
		||||
		t.Errorf("Expected first event message 'Inverter start', got %v", firstEvent.Message)
 | 
			
		||||
	}
 | 
			
		||||
	if firstEvent.StartTime != 1634567890 {
 | 
			
		||||
		t.Errorf("Expected first event start time 1634567890, got %v", firstEvent.StartTime)
 | 
			
		||||
	}
 | 
			
		||||
	if firstEvent.EndTime != 1634567950 {
 | 
			
		||||
		t.Errorf("Expected first event end time 1634567950, got %v", firstEvent.EndTime)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Check ongoing event (end_time = 0)
 | 
			
		||||
	ongoingEvent := eventsResponse.Events[2]
 | 
			
		||||
	if ongoingEvent.EndTime != 0 {
 | 
			
		||||
		t.Errorf("Expected ongoing event end time 0, got %v", ongoingEvent.EndTime)
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func TestConfigFromJSONFile(t *testing.T) {
 | 
			
		||||
	// Test config with auth
 | 
			
		||||
	data, err := loadTestData("test_config.json")
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		t.Fatalf("Failed to load config test data: %v", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	var config Config
 | 
			
		||||
	err = json.Unmarshal(data, &config)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		t.Fatalf("Failed to unmarshal Config: %v", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if config.DB != "postgres://user:password@localhost:5432/opendtu" {
 | 
			
		||||
		t.Errorf("Expected specific DB connection string, got %v", config.DB)
 | 
			
		||||
	}
 | 
			
		||||
	if config.OpenDTUAddress != "192.168.1.100" {
 | 
			
		||||
		t.Errorf("Expected OpenDTU address 192.168.1.100, got %v", config.OpenDTUAddress)
 | 
			
		||||
	}
 | 
			
		||||
	if !config.OpenDTUAuth {
 | 
			
		||||
		t.Errorf("Expected OpenDTU auth to be enabled")
 | 
			
		||||
	}
 | 
			
		||||
	if config.OpenDTUUser != "admin" {
 | 
			
		||||
		t.Errorf("Expected OpenDTU user 'admin', got %v", config.OpenDTUUser)
 | 
			
		||||
	}
 | 
			
		||||
	if !config.TimescaleDB {
 | 
			
		||||
		t.Errorf("Expected TimescaleDB to be enabled")
 | 
			
		||||
	}
 | 
			
		||||
	if config.TZ != "Europe/Amsterdam" {
 | 
			
		||||
		t.Errorf("Expected timezone Europe/Amsterdam, got %v", config.TZ)
 | 
			
		||||
	}
 | 
			
		||||
	if config.LogLevel != "INFO" {
 | 
			
		||||
		t.Errorf("Expected log level INFO, got %v", config.LogLevel)
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func TestQueryEventsEndpointMock(t *testing.T) {
 | 
			
		||||
	// Create test data
 | 
			
		||||
	testResponse := EventsResponse{
 | 
			
		||||
		Count: 2,
 | 
			
		||||
		Events: []Event{
 | 
			
		||||
			{
 | 
			
		||||
				MessageID: 1,
 | 
			
		||||
				Message:   "Test event 1",
 | 
			
		||||
				StartTime: 1634567890,
 | 
			
		||||
				EndTime:   1634567950,
 | 
			
		||||
			},
 | 
			
		||||
			{
 | 
			
		||||
				MessageID: 2,
 | 
			
		||||
				Message:   "Test event 2",
 | 
			
		||||
				StartTime: 1634568000,
 | 
			
		||||
				EndTime:   0, // Ongoing event
 | 
			
		||||
			},
 | 
			
		||||
		},
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Create a test server
 | 
			
		||||
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 | 
			
		||||
		// Check that the URL contains the expected inverter serial
 | 
			
		||||
		if r.URL.Query().Get("inv") != "123456789" {
 | 
			
		||||
			t.Errorf("Expected inverter serial 123456789, got %v", r.URL.Query().Get("inv"))
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Check the endpoint path
 | 
			
		||||
		if r.URL.Path != "/api/eventlog/status" {
 | 
			
		||||
			t.Errorf("Expected path /api/eventlog/status, got %v", r.URL.Path)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Return test data
 | 
			
		||||
		w.Header().Set("Content-Type", "application/json")
 | 
			
		||||
		json.NewEncoder(w).Encode(testResponse)
 | 
			
		||||
	}))
 | 
			
		||||
	defer server.Close()
 | 
			
		||||
 | 
			
		||||
	// Set config to use test server
 | 
			
		||||
	originalAddress := config.OpenDTUAddress
 | 
			
		||||
	config.OpenDTUAddress = server.URL[7:] // Remove "http://" prefix
 | 
			
		||||
	defer func() { config.OpenDTUAddress = originalAddress }()
 | 
			
		||||
 | 
			
		||||
	// Test the function
 | 
			
		||||
	response, err := queryEventsEndpoint("123456789")
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		t.Fatalf("queryEventsEndpoint failed: %v", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if response.Count != 2 {
 | 
			
		||||
		t.Errorf("Expected 2 events, got %v", response.Count)
 | 
			
		||||
	}
 | 
			
		||||
	if len(response.Events) != 2 {
 | 
			
		||||
		t.Errorf("Expected 2 events in array, got %v", len(response.Events))
 | 
			
		||||
	}
 | 
			
		||||
	if response.Events[0].Message != "Test event 1" {
 | 
			
		||||
		t.Errorf("Expected first event message 'Test event 1', got %v", response.Events[0].Message)
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func TestQueryEventsEndpointWithAuth(t *testing.T) {
 | 
			
		||||
	// Create a test server that checks auth
 | 
			
		||||
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 | 
			
		||||
		// Check for Authorization header
 | 
			
		||||
		auth := r.Header.Get("Authorization")
 | 
			
		||||
		expectedAuth := basicAuth("testuser", "testpass")
 | 
			
		||||
		if auth != expectedAuth {
 | 
			
		||||
			t.Errorf("Expected auth header %v, got %v", expectedAuth, auth)
 | 
			
		||||
			w.WriteHeader(http.StatusUnauthorized)
 | 
			
		||||
			return
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Return empty response for this test
 | 
			
		||||
		response := EventsResponse{Count: 0, Events: []Event{}}
 | 
			
		||||
		w.Header().Set("Content-Type", "application/json")
 | 
			
		||||
		json.NewEncoder(w).Encode(response)
 | 
			
		||||
	}))
 | 
			
		||||
	defer server.Close()
 | 
			
		||||
 | 
			
		||||
	// Set config to use test server with auth
 | 
			
		||||
	originalAddress := config.OpenDTUAddress
 | 
			
		||||
	originalAuth := config.OpenDTUAuth
 | 
			
		||||
	originalUser := config.OpenDTUUser
 | 
			
		||||
	originalPassword := config.OpenDTUPassword
 | 
			
		||||
 | 
			
		||||
	config.OpenDTUAddress = server.URL[7:] // Remove "http://" prefix
 | 
			
		||||
	config.OpenDTUAuth = true
 | 
			
		||||
	config.OpenDTUUser = "testuser"
 | 
			
		||||
	config.OpenDTUPassword = "testpass"
 | 
			
		||||
 | 
			
		||||
	defer func() {
 | 
			
		||||
		config.OpenDTUAddress = originalAddress
 | 
			
		||||
		config.OpenDTUAuth = originalAuth
 | 
			
		||||
		config.OpenDTUUser = originalUser
 | 
			
		||||
		config.OpenDTUPassword = originalPassword
 | 
			
		||||
	}()
 | 
			
		||||
 | 
			
		||||
	// Test the function
 | 
			
		||||
	response, err := queryEventsEndpoint("123456789")
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		t.Fatalf("queryEventsEndpoint with auth failed: %v", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if response.Count != 0 {
 | 
			
		||||
		t.Errorf("Expected 0 events, got %v", response.Count)
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func TestQueryEventsEndpointHTTPError(t *testing.T) {
 | 
			
		||||
	// Create a test server that returns an error
 | 
			
		||||
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 | 
			
		||||
		w.WriteHeader(http.StatusInternalServerError)
 | 
			
		||||
	}))
 | 
			
		||||
	defer server.Close()
 | 
			
		||||
 | 
			
		||||
	// Set config to use test server
 | 
			
		||||
	originalAddress := config.OpenDTUAddress
 | 
			
		||||
	config.OpenDTUAddress = server.URL[7:] // Remove "http://" prefix
 | 
			
		||||
	defer func() { config.OpenDTUAddress = originalAddress }()
 | 
			
		||||
 | 
			
		||||
	// Test the function
 | 
			
		||||
	_, err := queryEventsEndpoint("123456789")
 | 
			
		||||
	if err == nil {
 | 
			
		||||
		t.Fatalf("Expected error from queryEventsEndpoint, got nil")
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func TestHandleMessageWithLiveData(t *testing.T) {
 | 
			
		||||
	// Load test data
 | 
			
		||||
	data, err := loadTestData("livedata_producing.json")
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		t.Fatalf("Failed to load test data: %v", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Test that handleMessage doesn't crash with valid JSON
 | 
			
		||||
	// Note: This will not actually insert into DB since we don't have a test DB setup
 | 
			
		||||
	// handleMessage(data, nil) // Would panic without proper DB, so we skip this part
 | 
			
		||||
 | 
			
		||||
	// Instead, let's test the JSON parsing part
 | 
			
		||||
	var liveData LiveData
 | 
			
		||||
	err = json.Unmarshal(data, &liveData)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		t.Fatalf("handleMessage would fail due to JSON parsing error: %v", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Verify the conditions that would trigger data recording
 | 
			
		||||
	for _, inverter := range liveData.Inverters {
 | 
			
		||||
		if inverter.DataAge == 0 && inverter.Reachable {
 | 
			
		||||
			// This is the condition for recording data
 | 
			
		||||
			t.Logf("Inverter %s would have data recorded: DataAge=%d, Reachable=%v",
 | 
			
		||||
				inverter.Serial, inverter.DataAge, inverter.Reachable)
 | 
			
		||||
		}
 | 
			
		||||
		if inverter.DataAge == 0 && inverter.Events > 0 {
 | 
			
		||||
			// This is the condition for recording events
 | 
			
		||||
			t.Logf("Inverter %s would have events recorded: DataAge=%d, Events=%d",
 | 
			
		||||
				inverter.Serial, inverter.DataAge, inverter.Events)
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func TestInvalidJSON(t *testing.T) {
 | 
			
		||||
	invalidJSONs := []string{
 | 
			
		||||
		`{"invalid": json}`,
 | 
			
		||||
		`{"inverters": [{"serial": }]}`,
 | 
			
		||||
		`{"total": {"Power": {"v": "not a number"}}}`,
 | 
			
		||||
		``,
 | 
			
		||||
		`null`,
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	for i, jsonStr := range invalidJSONs {
 | 
			
		||||
		t.Run(fmt.Sprintf("invalid_json_%d", i), func(t *testing.T) {
 | 
			
		||||
			var liveData LiveData
 | 
			
		||||
			err := json.Unmarshal([]byte(jsonStr), &liveData)
 | 
			
		||||
			if err == nil && jsonStr != `` && jsonStr != `null` {
 | 
			
		||||
				t.Errorf("Expected error parsing invalid JSON %q, but got none", jsonStr)
 | 
			
		||||
			}
 | 
			
		||||
		})
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										436
									
								
								main_persistence_test.go
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										436
									
								
								main_persistence_test.go
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,436 @@
 | 
			
		|||
package main
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"context"
 | 
			
		||||
	"encoding/json"
 | 
			
		||||
	"errors"
 | 
			
		||||
	"log/slog"
 | 
			
		||||
	"net/http"
 | 
			
		||||
	"net/http/httptest"
 | 
			
		||||
	"strings"
 | 
			
		||||
	"testing"
 | 
			
		||||
 | 
			
		||||
	"github.com/DATA-DOG/go-sqlmock"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
func TestCreateLoggerWithLevel(t *testing.T) {
 | 
			
		||||
	logger := createLoggerWithLevel(slog.LevelWarn)
 | 
			
		||||
	if logger == nil {
 | 
			
		||||
		t.Fatal("expected logger instance")
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	handler := logger.Handler()
 | 
			
		||||
	if handler.Enabled(context.Background(), slog.LevelInfo) {
 | 
			
		||||
		t.Fatalf("expected info level to be disabled for warn handler")
 | 
			
		||||
	}
 | 
			
		||||
	if !handler.Enabled(context.Background(), slog.LevelError) {
 | 
			
		||||
		t.Fatalf("expected error level to be enabled for warn handler")
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func TestInsertLiveDataInsertsAllTables(t *testing.T) {
 | 
			
		||||
	db, mock, err := sqlmock.New()
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		t.Fatalf("failed to create sqlmock: %v", err)
 | 
			
		||||
	}
 | 
			
		||||
	defer db.Close()
 | 
			
		||||
 | 
			
		||||
	config = Config{TZ: "UTC"}
 | 
			
		||||
 | 
			
		||||
	inverter := Inverter{
 | 
			
		||||
		Serial:        "INV01",
 | 
			
		||||
		Name:          "Dummy Inverter",
 | 
			
		||||
		Producing:     true,
 | 
			
		||||
		LimitRelative: 90.5,
 | 
			
		||||
		LimitAbsolute: 1500,
 | 
			
		||||
		AC: map[string]InverterAC{
 | 
			
		||||
			"0": {
 | 
			
		||||
				Power:       VUD{V: 123.4},
 | 
			
		||||
				Voltage:     VUD{V: 230.0},
 | 
			
		||||
				Current:     VUD{V: 0.53},
 | 
			
		||||
				Frequency:   VUD{V: 50.0},
 | 
			
		||||
				PowerFactor: VUD{V: 0.99},
 | 
			
		||||
				ReactivePower: VUD{
 | 
			
		||||
					V: 1.1,
 | 
			
		||||
				},
 | 
			
		||||
			},
 | 
			
		||||
		},
 | 
			
		||||
		DC: map[string]InverterDC{
 | 
			
		||||
			"0": {
 | 
			
		||||
				Name: struct {
 | 
			
		||||
					U string `json:"u"`
 | 
			
		||||
				}{U: "Dummy String"},
 | 
			
		||||
				Power:      VUD{V: 111.1},
 | 
			
		||||
				Voltage:    VUD{V: 36.5},
 | 
			
		||||
				Current:    VUD{V: 3.05},
 | 
			
		||||
				YieldDay:   VUD{V: 12.0},
 | 
			
		||||
				YieldTotal: VUD{V: 456.7},
 | 
			
		||||
				Irradiation: struct {
 | 
			
		||||
					V   float64 `json:"v"`
 | 
			
		||||
					U   string  `json:"u"`
 | 
			
		||||
					D   int     `json:"d"`
 | 
			
		||||
					Max int     `json:"max"`
 | 
			
		||||
				}{V: 75.0, Max: 440},
 | 
			
		||||
			},
 | 
			
		||||
		},
 | 
			
		||||
		INV: map[string]InverterINV{
 | 
			
		||||
			"0": {
 | 
			
		||||
				Temperature: VUD{V: 40.0},
 | 
			
		||||
				Efficiency:  VUD{V: 97.5},
 | 
			
		||||
				PowerDC:     VUD{V: 222.2},
 | 
			
		||||
				YieldDay:    VUD{V: 15.0},
 | 
			
		||||
				YieldTotal:  VUD{V: 789.0},
 | 
			
		||||
			},
 | 
			
		||||
		},
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	total := Total{
 | 
			
		||||
		Power:    VUD{V: 321.0},
 | 
			
		||||
		YieldDay: VUD{V: 25.0},
 | 
			
		||||
		YieldTotal: VUD{
 | 
			
		||||
			V: 12345.6,
 | 
			
		||||
		},
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	hints := Hints{
 | 
			
		||||
		TimeSync:        true,
 | 
			
		||||
		RadioProblem:    true,
 | 
			
		||||
		DefaultPassword: false,
 | 
			
		||||
		PinMappingIssue: true,
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	mock.ExpectExec("INSERT INTO opendtu_log").
 | 
			
		||||
		WithArgs(sqlmock.AnyArg(), total.Power.V, total.YieldDay.V, total.YieldTotal.V).
 | 
			
		||||
		WillReturnResult(sqlmock.NewResult(0, 1))
 | 
			
		||||
 | 
			
		||||
	mock.ExpectExec("INSERT INTO opendtu_inverters").
 | 
			
		||||
		WithArgs(sqlmock.AnyArg(), inverter.Serial, inverter.Name, inverter.Producing, inverter.LimitRelative, inverter.LimitAbsolute).
 | 
			
		||||
		WillReturnResult(sqlmock.NewResult(0, 1))
 | 
			
		||||
 | 
			
		||||
	mock.ExpectExec("INSERT INTO opendtu_inverters_ac").
 | 
			
		||||
		WithArgs(sqlmock.AnyArg(), inverter.Serial, "0", inverter.AC["0"].Power.V, inverter.AC["0"].Voltage.V, inverter.AC["0"].Current.V, inverter.AC["0"].Frequency.V, inverter.AC["0"].PowerFactor.V, inverter.AC["0"].ReactivePower.V).
 | 
			
		||||
		WillReturnResult(sqlmock.NewResult(0, 1))
 | 
			
		||||
 | 
			
		||||
	mock.ExpectExec("INSERT INTO opendtu_inverters_dc").
 | 
			
		||||
		WithArgs(sqlmock.AnyArg(), inverter.Serial, "0", inverter.DC["0"].Name.U, inverter.DC["0"].Power.V, inverter.DC["0"].Voltage.V, inverter.DC["0"].Current.V, inverter.DC["0"].YieldDay.V, inverter.DC["0"].YieldTotal.V, inverter.DC["0"].Irradiation.V).
 | 
			
		||||
		WillReturnResult(sqlmock.NewResult(0, 1))
 | 
			
		||||
 | 
			
		||||
	mock.ExpectExec("INSERT INTO opendtu_inverters_inv").
 | 
			
		||||
		WithArgs(sqlmock.AnyArg(), inverter.Serial, inverter.INV["0"].Temperature.V, inverter.INV["0"].Efficiency.V, inverter.INV["0"].PowerDC.V, inverter.INV["0"].YieldDay.V, inverter.INV["0"].YieldTotal.V).
 | 
			
		||||
		WillReturnResult(sqlmock.NewResult(0, 1))
 | 
			
		||||
 | 
			
		||||
	mock.ExpectExec("INSERT INTO opendtu_hints").
 | 
			
		||||
		WithArgs(sqlmock.AnyArg(), hints.TimeSync, hints.RadioProblem, hints.DefaultPassword, hints.PinMappingIssue).
 | 
			
		||||
		WillReturnResult(sqlmock.NewResult(0, 1))
 | 
			
		||||
 | 
			
		||||
	insertLiveData(db, inverter, total, hints)
 | 
			
		||||
 | 
			
		||||
	if err := mock.ExpectationsWereMet(); err != nil {
 | 
			
		||||
		t.Fatalf("not all expectations were met: %v", err)
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func TestGetPreviousEventsCount(t *testing.T) {
 | 
			
		||||
	db, mock, err := sqlmock.New()
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		t.Fatalf("failed to create sqlmock: %v", err)
 | 
			
		||||
	}
 | 
			
		||||
	defer db.Close()
 | 
			
		||||
 | 
			
		||||
	rows := sqlmock.NewRows([]string{"count"}).AddRow(3)
 | 
			
		||||
	mock.ExpectQuery("SELECT COUNT\\(\\*\\)").
 | 
			
		||||
		WithArgs("INV01").
 | 
			
		||||
		WillReturnRows(rows)
 | 
			
		||||
 | 
			
		||||
	count := getPreviousEventsCount(db, "INV01")
 | 
			
		||||
	if count != 3 {
 | 
			
		||||
		t.Fatalf("expected count 3, got %d", count)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if err := mock.ExpectationsWereMet(); err != nil {
 | 
			
		||||
		t.Fatalf("not all expectations were met: %v", err)
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func TestInsertEventsPersistsRows(t *testing.T) {
 | 
			
		||||
	db, mock, err := sqlmock.New()
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		t.Fatalf("failed to create sqlmock: %v", err)
 | 
			
		||||
	}
 | 
			
		||||
	defer db.Close()
 | 
			
		||||
 | 
			
		||||
	config = Config{TZ: "UTC"}
 | 
			
		||||
 | 
			
		||||
	events := &EventsResponse{
 | 
			
		||||
		Events: []Event{
 | 
			
		||||
			{
 | 
			
		||||
				MessageID: 10,
 | 
			
		||||
				Message:   "Test event",
 | 
			
		||||
				StartTime: 111,
 | 
			
		||||
				EndTime:   222,
 | 
			
		||||
			},
 | 
			
		||||
		},
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	mock.ExpectExec("INSERT INTO opendtu_events").
 | 
			
		||||
		WithArgs(sqlmock.AnyArg(), "INV01", events.Events[0].MessageID, events.Events[0].Message, events.Events[0].StartTime, events.Events[0].EndTime).
 | 
			
		||||
		WillReturnResult(sqlmock.NewResult(0, 1))
 | 
			
		||||
 | 
			
		||||
	insertEvents(db, "INV01", events)
 | 
			
		||||
 | 
			
		||||
	if err := mock.ExpectationsWereMet(); err != nil {
 | 
			
		||||
		t.Fatalf("not all expectations were met: %v", err)
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func TestUpdateEventsUpdatesRows(t *testing.T) {
 | 
			
		||||
	db, mock, err := sqlmock.New()
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		t.Fatalf("failed to create sqlmock: %v", err)
 | 
			
		||||
	}
 | 
			
		||||
	defer db.Close()
 | 
			
		||||
 | 
			
		||||
	events := &EventsResponse{
 | 
			
		||||
		Events: []Event{
 | 
			
		||||
			{
 | 
			
		||||
				StartTime: 100,
 | 
			
		||||
				EndTime:   200,
 | 
			
		||||
			},
 | 
			
		||||
			{
 | 
			
		||||
				StartTime: 300,
 | 
			
		||||
				EndTime:   400,
 | 
			
		||||
			},
 | 
			
		||||
		},
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	mock.ExpectExec("UPDATE opendtu_events SET end_time").
 | 
			
		||||
		WithArgs(events.Events[0].EndTime, "INV01", events.Events[0].StartTime).
 | 
			
		||||
		WillReturnResult(sqlmock.NewResult(0, 1))
 | 
			
		||||
 | 
			
		||||
	mock.ExpectExec("UPDATE opendtu_events SET end_time").
 | 
			
		||||
		WithArgs(events.Events[1].EndTime, "INV01", events.Events[1].StartTime).
 | 
			
		||||
		WillReturnResult(sqlmock.NewResult(0, 1))
 | 
			
		||||
 | 
			
		||||
	updateEvents(db, "INV01", events)
 | 
			
		||||
 | 
			
		||||
	if err := mock.ExpectationsWereMet(); err != nil {
 | 
			
		||||
		t.Fatalf("not all expectations were met: %v", err)
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func TestHandleMessageRecordsDataAndEvents(t *testing.T) {
 | 
			
		||||
	db, mock, err := sqlmock.New()
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		t.Fatalf("failed to create sqlmock: %v", err)
 | 
			
		||||
	}
 | 
			
		||||
	defer db.Close()
 | 
			
		||||
 | 
			
		||||
	eventsResponse := EventsResponse{
 | 
			
		||||
		Count:  1,
 | 
			
		||||
		Events: []Event{{MessageID: 1, Message: "Test event", StartTime: 50, EndTime: 60}},
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 | 
			
		||||
		if r.URL.Path != "/api/eventlog/status" {
 | 
			
		||||
			t.Fatalf("unexpected path: %s", r.URL.Path)
 | 
			
		||||
		}
 | 
			
		||||
		if got := r.URL.Query().Get("inv"); got != "INV01" {
 | 
			
		||||
			t.Fatalf("unexpected inverter query: %s", got)
 | 
			
		||||
		}
 | 
			
		||||
		if err := json.NewEncoder(w).Encode(eventsResponse); err != nil {
 | 
			
		||||
			t.Fatalf("failed to write response: %v", err)
 | 
			
		||||
		}
 | 
			
		||||
	}))
 | 
			
		||||
	defer server.Close()
 | 
			
		||||
 | 
			
		||||
	previousConfig := config
 | 
			
		||||
	defer func() { config = previousConfig }()
 | 
			
		||||
 | 
			
		||||
	address := strings.TrimPrefix(server.URL, "http://")
 | 
			
		||||
	address = strings.TrimPrefix(address, "https://")
 | 
			
		||||
	config = Config{
 | 
			
		||||
		TZ:             "UTC",
 | 
			
		||||
		OpenDTUAddress: address,
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	rows := sqlmock.NewRows([]string{"count"}).AddRow(1)
 | 
			
		||||
	mock.ExpectQuery("SELECT COUNT\\(\\*\\)").
 | 
			
		||||
		WithArgs("INV01").
 | 
			
		||||
		WillReturnRows(rows)
 | 
			
		||||
 | 
			
		||||
	mock.ExpectExec("INSERT INTO opendtu_events").
 | 
			
		||||
		WithArgs(sqlmock.AnyArg(), "INV01", eventsResponse.Events[0].MessageID, eventsResponse.Events[0].Message, eventsResponse.Events[0].StartTime, eventsResponse.Events[0].EndTime).
 | 
			
		||||
		WillReturnResult(sqlmock.NewResult(0, 1))
 | 
			
		||||
 | 
			
		||||
	inverter := Inverter{
 | 
			
		||||
		Serial:        "INV01",
 | 
			
		||||
		Name:          "Dummy Inverter",
 | 
			
		||||
		Producing:     true,
 | 
			
		||||
		Reachable:     true,
 | 
			
		||||
		DataAge:       0,
 | 
			
		||||
		Events:        2,
 | 
			
		||||
		LimitRelative: 90.5,
 | 
			
		||||
		LimitAbsolute: 1500,
 | 
			
		||||
		AC: map[string]InverterAC{
 | 
			
		||||
			"0": {
 | 
			
		||||
				Power:         VUD{V: 123.4},
 | 
			
		||||
				Voltage:       VUD{V: 230.0},
 | 
			
		||||
				Current:       VUD{V: 0.53},
 | 
			
		||||
				Frequency:     VUD{V: 50.0},
 | 
			
		||||
				PowerFactor:   VUD{V: 0.99},
 | 
			
		||||
				ReactivePower: VUD{V: 1.1},
 | 
			
		||||
			},
 | 
			
		||||
		},
 | 
			
		||||
		DC: map[string]InverterDC{
 | 
			
		||||
			"0": {
 | 
			
		||||
				Name: struct {
 | 
			
		||||
					U string `json:"u"`
 | 
			
		||||
				}{U: "Dummy String"},
 | 
			
		||||
				Power:      VUD{V: 111.1},
 | 
			
		||||
				Voltage:    VUD{V: 36.5},
 | 
			
		||||
				Current:    VUD{V: 3.05},
 | 
			
		||||
				YieldDay:   VUD{V: 12.0},
 | 
			
		||||
				YieldTotal: VUD{V: 456.7},
 | 
			
		||||
				Irradiation: struct {
 | 
			
		||||
					V   float64 `json:"v"`
 | 
			
		||||
					U   string  `json:"u"`
 | 
			
		||||
					D   int     `json:"d"`
 | 
			
		||||
					Max int     `json:"max"`
 | 
			
		||||
				}{V: 75.0, Max: 440},
 | 
			
		||||
			},
 | 
			
		||||
		},
 | 
			
		||||
		INV: map[string]InverterINV{
 | 
			
		||||
			"0": {
 | 
			
		||||
				Temperature: VUD{V: 40.0},
 | 
			
		||||
				Efficiency:  VUD{V: 97.5},
 | 
			
		||||
				PowerDC:     VUD{V: 222.2},
 | 
			
		||||
				YieldDay:    VUD{V: 15.0},
 | 
			
		||||
				YieldTotal:  VUD{V: 789.0},
 | 
			
		||||
			},
 | 
			
		||||
		},
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	total := Total{
 | 
			
		||||
		Power:    VUD{V: 321.0},
 | 
			
		||||
		YieldDay: VUD{V: 25.0},
 | 
			
		||||
		YieldTotal: VUD{
 | 
			
		||||
			V: 12345.6,
 | 
			
		||||
		},
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	hints := Hints{
 | 
			
		||||
		TimeSync:        true,
 | 
			
		||||
		RadioProblem:    true,
 | 
			
		||||
		DefaultPassword: false,
 | 
			
		||||
		PinMappingIssue: true,
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	mock.ExpectExec("INSERT INTO opendtu_log").
 | 
			
		||||
		WithArgs(sqlmock.AnyArg(), total.Power.V, total.YieldDay.V, total.YieldTotal.V).
 | 
			
		||||
		WillReturnResult(sqlmock.NewResult(0, 1))
 | 
			
		||||
	mock.ExpectExec("INSERT INTO opendtu_inverters").
 | 
			
		||||
		WithArgs(sqlmock.AnyArg(), inverter.Serial, inverter.Name, inverter.Producing, inverter.LimitRelative, inverter.LimitAbsolute).
 | 
			
		||||
		WillReturnResult(sqlmock.NewResult(0, 1))
 | 
			
		||||
	mock.ExpectExec("INSERT INTO opendtu_inverters_ac").
 | 
			
		||||
		WithArgs(sqlmock.AnyArg(), inverter.Serial, "0", inverter.AC["0"].Power.V, inverter.AC["0"].Voltage.V, inverter.AC["0"].Current.V, inverter.AC["0"].Frequency.V, inverter.AC["0"].PowerFactor.V, inverter.AC["0"].ReactivePower.V).
 | 
			
		||||
		WillReturnResult(sqlmock.NewResult(0, 1))
 | 
			
		||||
	mock.ExpectExec("INSERT INTO opendtu_inverters_dc").
 | 
			
		||||
		WithArgs(sqlmock.AnyArg(), inverter.Serial, "0", inverter.DC["0"].Name.U, inverter.DC["0"].Power.V, inverter.DC["0"].Voltage.V, inverter.DC["0"].Current.V, inverter.DC["0"].YieldDay.V, inverter.DC["0"].YieldTotal.V, inverter.DC["0"].Irradiation.V).
 | 
			
		||||
		WillReturnResult(sqlmock.NewResult(0, 1))
 | 
			
		||||
	mock.ExpectExec("INSERT INTO opendtu_inverters_inv").
 | 
			
		||||
		WithArgs(sqlmock.AnyArg(), inverter.Serial, inverter.INV["0"].Temperature.V, inverter.INV["0"].Efficiency.V, inverter.INV["0"].PowerDC.V, inverter.INV["0"].YieldDay.V, inverter.INV["0"].YieldTotal.V).
 | 
			
		||||
		WillReturnResult(sqlmock.NewResult(0, 1))
 | 
			
		||||
	mock.ExpectExec("INSERT INTO opendtu_hints").
 | 
			
		||||
		WithArgs(sqlmock.AnyArg(), hints.TimeSync, hints.RadioProblem, hints.DefaultPassword, hints.PinMappingIssue).
 | 
			
		||||
		WillReturnResult(sqlmock.NewResult(0, 1))
 | 
			
		||||
 | 
			
		||||
	liveData := LiveData{
 | 
			
		||||
		Inverters: []Inverter{inverter},
 | 
			
		||||
		Total:     total,
 | 
			
		||||
		Hints:     hints,
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	payload, err := json.Marshal(liveData)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		t.Fatalf("failed to marshal live data: %v", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	handleMessage(payload, db)
 | 
			
		||||
 | 
			
		||||
	if err := mock.ExpectationsWereMet(); err != nil {
 | 
			
		||||
		t.Fatalf("not all expectations were met: %v", err)
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func TestHandleMessageSkipsWhenDataStale(t *testing.T) {
 | 
			
		||||
	db, mock, err := sqlmock.New()
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		t.Fatalf("failed to create sqlmock: %v", err)
 | 
			
		||||
	}
 | 
			
		||||
	defer db.Close()
 | 
			
		||||
 | 
			
		||||
	liveData := LiveData{
 | 
			
		||||
		Inverters: []Inverter{
 | 
			
		||||
			{
 | 
			
		||||
				Serial:    "INV01",
 | 
			
		||||
				Reachable: false,
 | 
			
		||||
				DataAge:   10,
 | 
			
		||||
				Events:    2,
 | 
			
		||||
			},
 | 
			
		||||
		},
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	payload, err := json.Marshal(liveData)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		t.Fatalf("failed to marshal live data: %v", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	handleMessage(payload, db)
 | 
			
		||||
 | 
			
		||||
	if err := mock.ExpectationsWereMet(); err != nil {
 | 
			
		||||
		t.Fatalf("unexpected database calls: %v", err)
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func TestEnableTimescaleHypertablesExecutesStatements(t *testing.T) {
 | 
			
		||||
	db, mock, err := sqlmock.New()
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		t.Fatalf("failed to create sqlmock: %v", err)
 | 
			
		||||
	}
 | 
			
		||||
	defer db.Close()
 | 
			
		||||
 | 
			
		||||
	mock.ExpectExec("(?s).*create_hypertable\\('opendtu_log'").
 | 
			
		||||
		WillReturnResult(sqlmock.NewResult(0, 1))
 | 
			
		||||
 | 
			
		||||
	if err := enableTimescaleHypertables(db); err != nil {
 | 
			
		||||
		t.Fatalf("expected no error, got %v", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if err := mock.ExpectationsWereMet(); err != nil {
 | 
			
		||||
		t.Fatalf("not all expectations were met: %v", err)
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func TestEnableTimescaleHypertablesPropagatesError(t *testing.T) {
 | 
			
		||||
	db, mock, err := sqlmock.New()
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		t.Fatalf("failed to create sqlmock: %v", err)
 | 
			
		||||
	}
 | 
			
		||||
	defer db.Close()
 | 
			
		||||
 | 
			
		||||
	mock.ExpectExec("(?s).*create_hypertable\\('opendtu_log'").
 | 
			
		||||
		WillReturnError(errors.New("boom"))
 | 
			
		||||
 | 
			
		||||
	err = enableTimescaleHypertables(db)
 | 
			
		||||
	if err == nil {
 | 
			
		||||
		t.Fatalf("expected error, got nil")
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if !strings.Contains(err.Error(), "boom") {
 | 
			
		||||
		t.Fatalf("expected wrapped boom error, got %v", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if err := mock.ExpectationsWereMet(); err != nil {
 | 
			
		||||
		t.Fatalf("expectations mismatch: %v", err)
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										566
									
								
								main_test.go
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										566
									
								
								main_test.go
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,566 @@
 | 
			
		|||
package main
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"encoding/json"
 | 
			
		||||
	"log/slog"
 | 
			
		||||
	"os"
 | 
			
		||||
	"testing"
 | 
			
		||||
	"time"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
func TestBasicAuth(t *testing.T) {
 | 
			
		||||
	tests := []struct {
 | 
			
		||||
		name     string
 | 
			
		||||
		username string
 | 
			
		||||
		password string
 | 
			
		||||
		expected string
 | 
			
		||||
	}{
 | 
			
		||||
		{
 | 
			
		||||
			name:     "basic auth test",
 | 
			
		||||
			username: "testuser",
 | 
			
		||||
			password: "testpass",
 | 
			
		||||
			expected: "Basic dGVzdHVzZXI6dGVzdHBhc3M=",
 | 
			
		||||
		},
 | 
			
		||||
		{
 | 
			
		||||
			name:     "empty credentials",
 | 
			
		||||
			username: "",
 | 
			
		||||
			password: "",
 | 
			
		||||
			expected: "Basic Og==",
 | 
			
		||||
		},
 | 
			
		||||
		{
 | 
			
		||||
			name:     "special characters",
 | 
			
		||||
			username: "user@domain.com",
 | 
			
		||||
			password: "p@ssw0rd!",
 | 
			
		||||
			expected: "Basic dXNlckBkb21haW4uY29tOnBAc3N3MHJkIQ==",
 | 
			
		||||
		},
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	for _, tt := range tests {
 | 
			
		||||
		t.Run(tt.name, func(t *testing.T) {
 | 
			
		||||
			result := basicAuth(tt.username, tt.password)
 | 
			
		||||
			if result != tt.expected {
 | 
			
		||||
				t.Errorf("basicAuth() = %v, want %v", result, tt.expected)
 | 
			
		||||
			}
 | 
			
		||||
		})
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func TestGetLogLevel(t *testing.T) {
 | 
			
		||||
	tests := []struct {
 | 
			
		||||
		name         string
 | 
			
		||||
		logLevel     string
 | 
			
		||||
		defaultLevel slog.Level
 | 
			
		||||
		expected     slog.Level
 | 
			
		||||
	}{
 | 
			
		||||
		{
 | 
			
		||||
			name:         "DEBUG level",
 | 
			
		||||
			logLevel:     "DEBUG",
 | 
			
		||||
			defaultLevel: slog.LevelInfo,
 | 
			
		||||
			expected:     slog.LevelDebug,
 | 
			
		||||
		},
 | 
			
		||||
		{
 | 
			
		||||
			name:         "INFO level",
 | 
			
		||||
			logLevel:     "INFO",
 | 
			
		||||
			defaultLevel: slog.LevelDebug,
 | 
			
		||||
			expected:     slog.LevelInfo,
 | 
			
		||||
		},
 | 
			
		||||
		{
 | 
			
		||||
			name:         "WARN level",
 | 
			
		||||
			logLevel:     "WARN",
 | 
			
		||||
			defaultLevel: slog.LevelDebug,
 | 
			
		||||
			expected:     slog.LevelWarn,
 | 
			
		||||
		},
 | 
			
		||||
		{
 | 
			
		||||
			name:         "ERROR level",
 | 
			
		||||
			logLevel:     "ERROR",
 | 
			
		||||
			defaultLevel: slog.LevelDebug,
 | 
			
		||||
			expected:     slog.LevelError,
 | 
			
		||||
		},
 | 
			
		||||
		{
 | 
			
		||||
			name:         "invalid level returns default",
 | 
			
		||||
			logLevel:     "INVALID",
 | 
			
		||||
			defaultLevel: slog.LevelInfo,
 | 
			
		||||
			expected:     slog.LevelInfo,
 | 
			
		||||
		},
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	for _, tt := range tests {
 | 
			
		||||
		t.Run(tt.name, func(t *testing.T) {
 | 
			
		||||
			// Temporarily set the config.LogLevel
 | 
			
		||||
			originalLogLevel := config.LogLevel
 | 
			
		||||
			config.LogLevel = tt.logLevel
 | 
			
		||||
			defer func() { config.LogLevel = originalLogLevel }()
 | 
			
		||||
 | 
			
		||||
			result := getLogLevel(tt.defaultLevel)
 | 
			
		||||
			if result != tt.expected {
 | 
			
		||||
				t.Errorf("getLogLevel() = %v, want %v", result, tt.expected)
 | 
			
		||||
			}
 | 
			
		||||
		})
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func TestVUDStruct(t *testing.T) {
 | 
			
		||||
	jsonData := `{"v": 123.45, "u": "W", "d": 2}`
 | 
			
		||||
 | 
			
		||||
	var vud VUD
 | 
			
		||||
	err := json.Unmarshal([]byte(jsonData), &vud)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		t.Fatalf("Failed to unmarshal VUD: %v", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if vud.V != 123.45 {
 | 
			
		||||
		t.Errorf("Expected V=123.45, got %v", vud.V)
 | 
			
		||||
	}
 | 
			
		||||
	if vud.U != "W" {
 | 
			
		||||
		t.Errorf("Expected U=W, got %v", vud.U)
 | 
			
		||||
	}
 | 
			
		||||
	if vud.D != 2 {
 | 
			
		||||
		t.Errorf("Expected D=2, got %v", vud.D)
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func TestInverterStruct(t *testing.T) {
 | 
			
		||||
	jsonData := `{
 | 
			
		||||
		"serial": "123456789",
 | 
			
		||||
		"name": "Test Inverter",
 | 
			
		||||
		"producing": true,
 | 
			
		||||
		"limit_relative": 80.5,
 | 
			
		||||
		"limit_absolute": 800.0,
 | 
			
		||||
		"AC": {
 | 
			
		||||
			"0": {
 | 
			
		||||
				"Power": {"v": 250.5, "u": "W", "d": 1},
 | 
			
		||||
				"Voltage": {"v": 230.0, "u": "V", "d": 1},
 | 
			
		||||
				"Current": {"v": 1.09, "u": "A", "d": 2},
 | 
			
		||||
				"Frequency": {"v": 50.0, "u": "Hz", "d": 1},
 | 
			
		||||
				"PowerFactor": {"v": 1.0, "u": "", "d": 2},
 | 
			
		||||
				"ReactivePower": {"v": 0.0, "u": "var", "d": 1}
 | 
			
		||||
			}
 | 
			
		||||
		},
 | 
			
		||||
		"DC": {
 | 
			
		||||
			"0": {
 | 
			
		||||
				"Name": {"u": "String 1"},
 | 
			
		||||
				"Power": {"v": 260.0, "u": "W", "d": 1},
 | 
			
		||||
				"Voltage": {"v": 35.2, "u": "V", "d": 1},
 | 
			
		||||
				"Current": {"v": 7.4, "u": "A", "d": 1},
 | 
			
		||||
				"YieldDay": {"v": 2.5, "u": "kWh", "d": 3},
 | 
			
		||||
				"YieldTotal": {"v": 1250.8, "u": "kWh", "d": 3},
 | 
			
		||||
				"Irradiation": {"v": 85.2, "u": "%", "d": 1, "max": 100}
 | 
			
		||||
			}
 | 
			
		||||
		},
 | 
			
		||||
		"events": 2,
 | 
			
		||||
		"poll_enabled": true,
 | 
			
		||||
		"reachable": true,
 | 
			
		||||
		"data_age": 0,
 | 
			
		||||
		"INV": {
 | 
			
		||||
			"0": {
 | 
			
		||||
				"Temperature": {"v": 32.5, "u": "°C", "d": 1},
 | 
			
		||||
				"Efficiency": {"v": 96.2, "u": "%", "d": 1},
 | 
			
		||||
				"Power DC": {"v": 260.0, "u": "W", "d": 1},
 | 
			
		||||
				"YieldDay": {"v": 2.5, "u": "kWh", "d": 3},
 | 
			
		||||
				"YieldTotal": {"v": 1250.8, "u": "kWh", "d": 3}
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
	}`
 | 
			
		||||
 | 
			
		||||
	var inverter Inverter
 | 
			
		||||
	err := json.Unmarshal([]byte(jsonData), &inverter)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		t.Fatalf("Failed to unmarshal Inverter: %v", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if inverter.Serial != "123456789" {
 | 
			
		||||
		t.Errorf("Expected Serial=123456789, got %v", inverter.Serial)
 | 
			
		||||
	}
 | 
			
		||||
	if inverter.Name != "Test Inverter" {
 | 
			
		||||
		t.Errorf("Expected Name=Test Inverter, got %v", inverter.Name)
 | 
			
		||||
	}
 | 
			
		||||
	if !inverter.Producing {
 | 
			
		||||
		t.Errorf("Expected Producing=true, got %v", inverter.Producing)
 | 
			
		||||
	}
 | 
			
		||||
	if inverter.LimitRelative != 80.5 {
 | 
			
		||||
		t.Errorf("Expected LimitRelative=80.5, got %v", inverter.LimitRelative)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Test AC data
 | 
			
		||||
	if len(inverter.AC) != 1 {
 | 
			
		||||
		t.Errorf("Expected 1 AC entry, got %v", len(inverter.AC))
 | 
			
		||||
	}
 | 
			
		||||
	if ac, exists := inverter.AC["0"]; exists {
 | 
			
		||||
		if ac.Power.V != 250.5 {
 | 
			
		||||
			t.Errorf("Expected AC Power=250.5, got %v", ac.Power.V)
 | 
			
		||||
		}
 | 
			
		||||
		if ac.Voltage.V != 230.0 {
 | 
			
		||||
			t.Errorf("Expected AC Voltage=230.0, got %v", ac.Voltage.V)
 | 
			
		||||
		}
 | 
			
		||||
	} else {
 | 
			
		||||
		t.Error("Expected AC[0] to exist")
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Test DC data
 | 
			
		||||
	if len(inverter.DC) != 1 {
 | 
			
		||||
		t.Errorf("Expected 1 DC entry, got %v", len(inverter.DC))
 | 
			
		||||
	}
 | 
			
		||||
	if dc, exists := inverter.DC["0"]; exists {
 | 
			
		||||
		if dc.Name.U != "String 1" {
 | 
			
		||||
			t.Errorf("Expected DC Name=String 1, got %v", dc.Name.U)
 | 
			
		||||
		}
 | 
			
		||||
		if dc.Power.V != 260.0 {
 | 
			
		||||
			t.Errorf("Expected DC Power=260.0, got %v", dc.Power.V)
 | 
			
		||||
		}
 | 
			
		||||
	} else {
 | 
			
		||||
		t.Error("Expected DC[0] to exist")
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Test INV data
 | 
			
		||||
	if len(inverter.INV) != 1 {
 | 
			
		||||
		t.Errorf("Expected 1 INV entry, got %v", len(inverter.INV))
 | 
			
		||||
	}
 | 
			
		||||
	if inv, exists := inverter.INV["0"]; exists {
 | 
			
		||||
		if inv.Temperature.V != 32.5 {
 | 
			
		||||
			t.Errorf("Expected INV Temperature=32.5, got %v", inv.Temperature.V)
 | 
			
		||||
		}
 | 
			
		||||
		if inv.Efficiency.V != 96.2 {
 | 
			
		||||
			t.Errorf("Expected INV Efficiency=96.2, got %v", inv.Efficiency.V)
 | 
			
		||||
		}
 | 
			
		||||
	} else {
 | 
			
		||||
		t.Error("Expected INV[0] to exist")
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func TestEventStruct(t *testing.T) {
 | 
			
		||||
	jsonData := `{
 | 
			
		||||
		"message_id": 1,
 | 
			
		||||
		"message": "Test event message",
 | 
			
		||||
		"start_time": 1634567890,
 | 
			
		||||
		"end_time": 1634567950
 | 
			
		||||
	}`
 | 
			
		||||
 | 
			
		||||
	var event Event
 | 
			
		||||
	err := json.Unmarshal([]byte(jsonData), &event)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		t.Fatalf("Failed to unmarshal Event: %v", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if event.MessageID != 1 {
 | 
			
		||||
		t.Errorf("Expected MessageID=1, got %v", event.MessageID)
 | 
			
		||||
	}
 | 
			
		||||
	if event.Message != "Test event message" {
 | 
			
		||||
		t.Errorf("Expected Message=Test event message, got %v", event.Message)
 | 
			
		||||
	}
 | 
			
		||||
	if event.StartTime != 1634567890 {
 | 
			
		||||
		t.Errorf("Expected StartTime=1634567890, got %v", event.StartTime)
 | 
			
		||||
	}
 | 
			
		||||
	if event.EndTime != 1634567950 {
 | 
			
		||||
		t.Errorf("Expected EndTime=1634567950, got %v", event.EndTime)
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func TestLiveDataStruct(t *testing.T) {
 | 
			
		||||
	jsonData := `{
 | 
			
		||||
		"inverters": [
 | 
			
		||||
			{
 | 
			
		||||
				"serial": "123456789",
 | 
			
		||||
				"name": "Test Inverter",
 | 
			
		||||
				"producing": true,
 | 
			
		||||
				"limit_relative": 80.5,
 | 
			
		||||
				"limit_absolute": 800.0,
 | 
			
		||||
				"AC": {},
 | 
			
		||||
				"DC": {},
 | 
			
		||||
				"events": 0,
 | 
			
		||||
				"poll_enabled": true,
 | 
			
		||||
				"reachable": true,
 | 
			
		||||
				"data_age": 0,
 | 
			
		||||
				"INV": {}
 | 
			
		||||
			}
 | 
			
		||||
		],
 | 
			
		||||
		"total": {
 | 
			
		||||
			"Power": {"v": 250.5, "u": "W", "d": 1},
 | 
			
		||||
			"YieldDay": {"v": 2.5, "u": "kWh", "d": 3},
 | 
			
		||||
			"YieldTotal": {"v": 1250.8, "u": "kWh", "d": 3}
 | 
			
		||||
		},
 | 
			
		||||
		"hints": {
 | 
			
		||||
			"time_sync": true,
 | 
			
		||||
			"radio_problem": false,
 | 
			
		||||
			"default_password": false
 | 
			
		||||
		}
 | 
			
		||||
	}`
 | 
			
		||||
 | 
			
		||||
	var liveData LiveData
 | 
			
		||||
	err := json.Unmarshal([]byte(jsonData), &liveData)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		t.Fatalf("Failed to unmarshal LiveData: %v", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if len(liveData.Inverters) != 1 {
 | 
			
		||||
		t.Errorf("Expected 1 inverter, got %v", len(liveData.Inverters))
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if liveData.Total.Power.V != 250.5 {
 | 
			
		||||
		t.Errorf("Expected Total Power=250.5, got %v", liveData.Total.Power.V)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if !liveData.Hints.TimeSync {
 | 
			
		||||
		t.Errorf("Expected Hints.TimeSync=true, got %v", liveData.Hints.TimeSync)
 | 
			
		||||
	}
 | 
			
		||||
	if liveData.Hints.RadioProblem {
 | 
			
		||||
		t.Errorf("Expected Hints.RadioProblem=false, got %v", liveData.Hints.RadioProblem)
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func TestConfigLoadFromEnv(t *testing.T) {
 | 
			
		||||
	// Save original environment variables
 | 
			
		||||
	originalDB := os.Getenv("DB_URL")
 | 
			
		||||
	originalAddress := os.Getenv("OPENDTU_ADDRESS")
 | 
			
		||||
	originalAuth := os.Getenv("OPENDTU_AUTH")
 | 
			
		||||
	originalUser := os.Getenv("OPENDTU_USERNAME")
 | 
			
		||||
	originalPassword := os.Getenv("OPENDTU_PASSWORD")
 | 
			
		||||
	originalTimescale := os.Getenv("TIMESCALEDB_ENABLED")
 | 
			
		||||
	originalTZ := os.Getenv("TZ")
 | 
			
		||||
	originalLogLevel := os.Getenv("LOG_LEVEL")
 | 
			
		||||
	originalConfigFile := os.Getenv("CONFIG_FILE")
 | 
			
		||||
 | 
			
		||||
	// Clean up after test
 | 
			
		||||
	defer func() {
 | 
			
		||||
		os.Setenv("DB_URL", originalDB)
 | 
			
		||||
		os.Setenv("OPENDTU_ADDRESS", originalAddress)
 | 
			
		||||
		os.Setenv("OPENDTU_AUTH", originalAuth)
 | 
			
		||||
		os.Setenv("OPENDTU_USERNAME", originalUser)
 | 
			
		||||
		os.Setenv("OPENDTU_PASSWORD", originalPassword)
 | 
			
		||||
		os.Setenv("TIMESCALEDB_ENABLED", originalTimescale)
 | 
			
		||||
		os.Setenv("TZ", originalTZ)
 | 
			
		||||
		os.Setenv("LOG_LEVEL", originalLogLevel)
 | 
			
		||||
		os.Setenv("CONFIG_FILE", originalConfigFile)
 | 
			
		||||
	}()
 | 
			
		||||
 | 
			
		||||
	// Set test environment variables
 | 
			
		||||
	os.Setenv("CONFIG_FILE", "/nonexistent/config.json") // Force env var fallback
 | 
			
		||||
	os.Setenv("DB_URL", "postgres://testuser:testpass@localhost/testdb")
 | 
			
		||||
	os.Setenv("OPENDTU_ADDRESS", "192.168.1.100")
 | 
			
		||||
	os.Setenv("OPENDTU_AUTH", "true")
 | 
			
		||||
	os.Setenv("OPENDTU_USERNAME", "admin")
 | 
			
		||||
	os.Setenv("OPENDTU_PASSWORD", "secret")
 | 
			
		||||
	os.Setenv("TIMESCALEDB_ENABLED", "true")
 | 
			
		||||
	os.Setenv("TZ", "Europe/Amsterdam")
 | 
			
		||||
	os.Setenv("LOG_LEVEL", "DEBUG")
 | 
			
		||||
 | 
			
		||||
	config, err := loadConfig()
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		t.Fatalf("loadConfig() failed: %v", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if config.DB != "postgres://testuser:testpass@localhost/testdb" {
 | 
			
		||||
		t.Errorf("Expected DB from env, got %v", config.DB)
 | 
			
		||||
	}
 | 
			
		||||
	if config.OpenDTUAddress != "192.168.1.100" {
 | 
			
		||||
		t.Errorf("Expected OpenDTUAddress from env, got %v", config.OpenDTUAddress)
 | 
			
		||||
	}
 | 
			
		||||
	if !config.OpenDTUAuth {
 | 
			
		||||
		t.Errorf("Expected OpenDTUAuth=true from env, got %v", config.OpenDTUAuth)
 | 
			
		||||
	}
 | 
			
		||||
	if config.OpenDTUUser != "admin" {
 | 
			
		||||
		t.Errorf("Expected OpenDTUUser from env, got %v", config.OpenDTUUser)
 | 
			
		||||
	}
 | 
			
		||||
	if config.OpenDTUPassword != "secret" {
 | 
			
		||||
		t.Errorf("Expected OpenDTUPassword from env, got %v", config.OpenDTUPassword)
 | 
			
		||||
	}
 | 
			
		||||
	if !config.TimescaleDB {
 | 
			
		||||
		t.Errorf("Expected TimescaleDB=true from env, got %v", config.TimescaleDB)
 | 
			
		||||
	}
 | 
			
		||||
	if config.TZ != "Europe/Amsterdam" {
 | 
			
		||||
		t.Errorf("Expected TZ from env, got %v", config.TZ)
 | 
			
		||||
	}
 | 
			
		||||
	if config.LogLevel != "DEBUG" {
 | 
			
		||||
		t.Errorf("Expected LogLevel from env, got %v", config.LogLevel)
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func TestTimeZoneValidation(t *testing.T) {
 | 
			
		||||
	validTimezones := []string{
 | 
			
		||||
		"UTC",
 | 
			
		||||
		"Europe/Amsterdam",
 | 
			
		||||
		"America/New_York",
 | 
			
		||||
		"Asia/Tokyo",
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	for _, tz := range validTimezones {
 | 
			
		||||
		t.Run(tz, func(t *testing.T) {
 | 
			
		||||
			_, err := time.LoadLocation(tz)
 | 
			
		||||
			if err != nil {
 | 
			
		||||
				t.Errorf("Timezone %s should be valid but got error: %v", tz, err)
 | 
			
		||||
			}
 | 
			
		||||
		})
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	invalidTimezones := []string{
 | 
			
		||||
		"Invalid/Timezone",
 | 
			
		||||
		"Not/A/Real/Place",
 | 
			
		||||
		"",
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	for _, tz := range invalidTimezones {
 | 
			
		||||
		if tz == "" {
 | 
			
		||||
			continue // Empty string is handled differently
 | 
			
		||||
		}
 | 
			
		||||
		t.Run(tz, func(t *testing.T) {
 | 
			
		||||
			_, err := time.LoadLocation(tz)
 | 
			
		||||
			if err == nil {
 | 
			
		||||
				t.Errorf("Timezone %s should be invalid but was accepted", tz)
 | 
			
		||||
			}
 | 
			
		||||
		})
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Test RadioStatistics structure (from official OpenDTU)
 | 
			
		||||
func TestRadioStatisticsStruct(t *testing.T) {
 | 
			
		||||
	jsonData := `{
 | 
			
		||||
		"tx_request": 12345,
 | 
			
		||||
		"tx_re_request": 234,
 | 
			
		||||
		"rx_success": 12000,
 | 
			
		||||
		"rx_fail_nothing": 50,
 | 
			
		||||
		"rx_fail_partial": 30,
 | 
			
		||||
		"rx_fail_corrupt": 21,
 | 
			
		||||
		"rssi": -65.5
 | 
			
		||||
	}`
 | 
			
		||||
 | 
			
		||||
	var stats RadioStatistics
 | 
			
		||||
	err := json.Unmarshal([]byte(jsonData), &stats)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		t.Fatalf("Failed to unmarshal RadioStatistics: %v", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if stats.TxRequest != 12345 {
 | 
			
		||||
		t.Errorf("Expected TxRequest=12345, got %v", stats.TxRequest)
 | 
			
		||||
	}
 | 
			
		||||
	if stats.TxReRequest != 234 {
 | 
			
		||||
		t.Errorf("Expected TxReRequest=234, got %v", stats.TxReRequest)
 | 
			
		||||
	}
 | 
			
		||||
	if stats.RxSuccess != 12000 {
 | 
			
		||||
		t.Errorf("Expected RxSuccess=12000, got %v", stats.RxSuccess)
 | 
			
		||||
	}
 | 
			
		||||
	if stats.RxFailNothing != 50 {
 | 
			
		||||
		t.Errorf("Expected RxFailNothing=50, got %v", stats.RxFailNothing)
 | 
			
		||||
	}
 | 
			
		||||
	if stats.RxFailPartial != 30 {
 | 
			
		||||
		t.Errorf("Expected RxFailPartial=30, got %v", stats.RxFailPartial)
 | 
			
		||||
	}
 | 
			
		||||
	if stats.RxFailCorrupt != 21 {
 | 
			
		||||
		t.Errorf("Expected RxFailCorrupt=21, got %v", stats.RxFailCorrupt)
 | 
			
		||||
	}
 | 
			
		||||
	if stats.RSSI != -65.5 {
 | 
			
		||||
		t.Errorf("Expected RSSI=-65.5, got %v", stats.RSSI)
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Test inverter struct with new OpenDTU fields: order, data_age_ms, radio_stats
 | 
			
		||||
func TestInverterWithOpenDTUFields(t *testing.T) {
 | 
			
		||||
	jsonData := `{
 | 
			
		||||
		"serial": "114173123456",
 | 
			
		||||
		"name": "Hoymiles HM-800",
 | 
			
		||||
		"order": 0,
 | 
			
		||||
		"data_age": 0,
 | 
			
		||||
		"data_age_ms": 124,
 | 
			
		||||
		"poll_enabled": true,
 | 
			
		||||
		"reachable": true,
 | 
			
		||||
		"producing": true,
 | 
			
		||||
		"limit_relative": 100.0,
 | 
			
		||||
		"limit_absolute": 800.0,
 | 
			
		||||
		"events": 2,
 | 
			
		||||
		"radio_stats": {
 | 
			
		||||
			"tx_request": 12345,
 | 
			
		||||
			"tx_re_request": 234,
 | 
			
		||||
			"rx_success": 12000,
 | 
			
		||||
			"rx_fail_nothing": 50,
 | 
			
		||||
			"rx_fail_partial": 30,
 | 
			
		||||
			"rx_fail_corrupt": 21,
 | 
			
		||||
			"rssi": -65.5
 | 
			
		||||
		},
 | 
			
		||||
		"AC": {},
 | 
			
		||||
		"DC": {},
 | 
			
		||||
		"INV": {}
 | 
			
		||||
	}`
 | 
			
		||||
 | 
			
		||||
	var inverter Inverter
 | 
			
		||||
	err := json.Unmarshal([]byte(jsonData), &inverter)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		t.Fatalf("Failed to unmarshal Inverter with OpenDTU fields: %v", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Test new fields from official OpenDTU
 | 
			
		||||
	if inverter.Order != 0 {
 | 
			
		||||
		t.Errorf("Expected Order=0, got %v", inverter.Order)
 | 
			
		||||
	}
 | 
			
		||||
	if inverter.DataAge != 0 {
 | 
			
		||||
		t.Errorf("Expected DataAge=0, got %v", inverter.DataAge)
 | 
			
		||||
	}
 | 
			
		||||
	if inverter.DataAgeMs != 124 {
 | 
			
		||||
		t.Errorf("Expected DataAgeMs=124, got %v", inverter.DataAgeMs)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Test radio_stats
 | 
			
		||||
	if inverter.RadioStats.TxRequest != 12345 {
 | 
			
		||||
		t.Errorf("Expected RadioStats.TxRequest=12345, got %v", inverter.RadioStats.TxRequest)
 | 
			
		||||
	}
 | 
			
		||||
	if inverter.RadioStats.RxSuccess != 12000 {
 | 
			
		||||
		t.Errorf("Expected RadioStats.RxSuccess=12000, got %v", inverter.RadioStats.RxSuccess)
 | 
			
		||||
	}
 | 
			
		||||
	if inverter.RadioStats.RSSI != -65.5 {
 | 
			
		||||
		t.Errorf("Expected RadioStats.RSSI=-65.5, got %v", inverter.RadioStats.RSSI)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Test original fields still work
 | 
			
		||||
	if inverter.Serial != "114173123456" {
 | 
			
		||||
		t.Errorf("Expected Serial=114173123456, got %v", inverter.Serial)
 | 
			
		||||
	}
 | 
			
		||||
	if inverter.Name != "Hoymiles HM-800" {
 | 
			
		||||
		t.Errorf("Expected Name=Hoymiles HM-800, got %v", inverter.Name)
 | 
			
		||||
	}
 | 
			
		||||
	if !inverter.Producing {
 | 
			
		||||
		t.Errorf("Expected Producing=true, got %v", inverter.Producing)
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Test Hints struct with new pin_mapping_issue field from OpenDTU
 | 
			
		||||
func TestHintsWithPinMappingIssue(t *testing.T) {
 | 
			
		||||
	jsonData := `{
 | 
			
		||||
		"time_sync": true,
 | 
			
		||||
		"radio_problem": false,
 | 
			
		||||
		"default_password": false,
 | 
			
		||||
		"pin_mapping_issue": false
 | 
			
		||||
	}`
 | 
			
		||||
 | 
			
		||||
	var hints Hints
 | 
			
		||||
	err := json.Unmarshal([]byte(jsonData), &hints)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		t.Fatalf("Failed to unmarshal Hints with pin_mapping_issue: %v", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if !hints.TimeSync {
 | 
			
		||||
		t.Errorf("Expected TimeSync=true, got %v", hints.TimeSync)
 | 
			
		||||
	}
 | 
			
		||||
	if hints.RadioProblem {
 | 
			
		||||
		t.Errorf("Expected RadioProblem=false, got %v", hints.RadioProblem)
 | 
			
		||||
	}
 | 
			
		||||
	if hints.DefaultPassword {
 | 
			
		||||
		t.Errorf("Expected DefaultPassword=false, got %v", hints.DefaultPassword)
 | 
			
		||||
	}
 | 
			
		||||
	if hints.PinMappingIssue {
 | 
			
		||||
		t.Errorf("Expected PinMappingIssue=false, got %v", hints.PinMappingIssue)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Test when pin_mapping_issue is true
 | 
			
		||||
	jsonDataWithIssue := `{
 | 
			
		||||
		"time_sync": true,
 | 
			
		||||
		"radio_problem": false,
 | 
			
		||||
		"default_password": false,
 | 
			
		||||
		"pin_mapping_issue": true
 | 
			
		||||
	}`
 | 
			
		||||
 | 
			
		||||
	err = json.Unmarshal([]byte(jsonDataWithIssue), &hints)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		t.Fatalf("Failed to unmarshal Hints with pin_mapping_issue=true: %v", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if !hints.PinMappingIssue {
 | 
			
		||||
		t.Errorf("Expected PinMappingIssue=true, got %v", hints.PinMappingIssue)
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										500
									
								
								opendtu_compatibility_test.go
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										500
									
								
								opendtu_compatibility_test.go
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,500 @@
 | 
			
		|||
package main
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"encoding/json"
 | 
			
		||||
	"testing"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
// TestOpenDTUCompatibility_FullStructure validates that our Go structs
 | 
			
		||||
// can parse the exact JSON structure produced by OpenDTU WebSocket
 | 
			
		||||
// based on opendtu/src/WebApi_ws_live.cpp
 | 
			
		||||
func TestOpenDTUCompatibility_FullStructure(t *testing.T) {
 | 
			
		||||
	data, err := loadTestData("livedata_producing.json")
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		t.Fatalf("Failed to load test data: %v", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	var liveData LiveData
 | 
			
		||||
	err = json.Unmarshal(data, &liveData)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		t.Fatalf("Failed to unmarshal LiveData: %v", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Validate top-level structure
 | 
			
		||||
	if len(liveData.Inverters) == 0 {
 | 
			
		||||
		t.Fatal("Expected at least one inverter")
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Test first inverter completely
 | 
			
		||||
	inv := liveData.Inverters[0]
 | 
			
		||||
 | 
			
		||||
	// Validate all root-level inverter fields (from generateInverterCommonJsonResponse)
 | 
			
		||||
	t.Run("InverterCommonFields", func(t *testing.T) {
 | 
			
		||||
		if inv.Serial == "" {
 | 
			
		||||
			t.Error("serial field missing or empty")
 | 
			
		||||
		}
 | 
			
		||||
		if inv.Name == "" {
 | 
			
		||||
			t.Error("name field missing or empty")
 | 
			
		||||
		}
 | 
			
		||||
		// order field (int, can be 0)
 | 
			
		||||
		if inv.Order < 0 {
 | 
			
		||||
			t.Error("order field has invalid value")
 | 
			
		||||
		}
 | 
			
		||||
		// data_age field (int, can be 0)
 | 
			
		||||
		if inv.DataAge < 0 {
 | 
			
		||||
			t.Error("data_age field has invalid value")
 | 
			
		||||
		}
 | 
			
		||||
		// data_age_ms field (int, can be 0)
 | 
			
		||||
		if inv.DataAgeMs < 0 {
 | 
			
		||||
			t.Error("data_age_ms field has invalid value")
 | 
			
		||||
		}
 | 
			
		||||
		// poll_enabled field (bool)
 | 
			
		||||
		_ = inv.PollEnabled
 | 
			
		||||
		// reachable field (bool)
 | 
			
		||||
		_ = inv.Reachable
 | 
			
		||||
		// producing field (bool)
 | 
			
		||||
		_ = inv.Producing
 | 
			
		||||
		// limit_relative field (float64)
 | 
			
		||||
		if inv.LimitRelative < 0 {
 | 
			
		||||
			t.Error("limit_relative field has invalid value")
 | 
			
		||||
		}
 | 
			
		||||
		// limit_absolute field (float64, can be -1)
 | 
			
		||||
		_ = inv.LimitAbsolute
 | 
			
		||||
	})
 | 
			
		||||
 | 
			
		||||
	// Validate radio_stats structure (from generateInverterCommonJsonResponse lines 157-163)
 | 
			
		||||
	t.Run("RadioStats", func(t *testing.T) {
 | 
			
		||||
		rs := inv.RadioStats
 | 
			
		||||
		// All fields from OpenDTU RadioStats
 | 
			
		||||
		if rs.TxRequest < 0 {
 | 
			
		||||
			t.Error("radio_stats.tx_request has invalid value")
 | 
			
		||||
		}
 | 
			
		||||
		if rs.TxReRequest < 0 {
 | 
			
		||||
			t.Error("radio_stats.tx_re_request has invalid value")
 | 
			
		||||
		}
 | 
			
		||||
		if rs.RxSuccess < 0 {
 | 
			
		||||
			t.Error("radio_stats.rx_success has invalid value")
 | 
			
		||||
		}
 | 
			
		||||
		if rs.RxFailNothing < 0 {
 | 
			
		||||
			t.Error("radio_stats.rx_fail_nothing has invalid value")
 | 
			
		||||
		}
 | 
			
		||||
		if rs.RxFailPartial < 0 {
 | 
			
		||||
			t.Error("radio_stats.rx_fail_partial has invalid value")
 | 
			
		||||
		}
 | 
			
		||||
		if rs.RxFailCorrupt < 0 {
 | 
			
		||||
			t.Error("radio_stats.rx_fail_corrupt has invalid value")
 | 
			
		||||
		}
 | 
			
		||||
		// rssi can be negative (signal strength)
 | 
			
		||||
		_ = rs.RSSI
 | 
			
		||||
	})
 | 
			
		||||
 | 
			
		||||
	// Validate AC channel structure (string-keyed map from WebApi_ws_live.cpp line 224)
 | 
			
		||||
	t.Run("AC_Structure", func(t *testing.T) {
 | 
			
		||||
		if len(inv.AC) == 0 {
 | 
			
		||||
			t.Fatal("AC map is empty")
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// OpenDTU uses string keys: "0", "1", etc.
 | 
			
		||||
		ac0, exists := inv.AC["0"]
 | 
			
		||||
		if !exists {
 | 
			
		||||
			t.Fatal("AC[\"0\"] does not exist - OpenDTU uses string-keyed maps")
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Validate AC fields (from addField calls in WebApi_ws_live.cpp)
 | 
			
		||||
		// Lines 184-188: FLD_PAC, FLD_UAC, FLD_IAC, FLD_F, FLD_PF, FLD_Q
 | 
			
		||||
		validateVUD(t, "AC.Power", ac0.Power)
 | 
			
		||||
		validateVUD(t, "AC.Voltage", ac0.Voltage)
 | 
			
		||||
		validateVUD(t, "AC.Current", ac0.Current)
 | 
			
		||||
		validateVUD(t, "AC.Frequency", ac0.Frequency)
 | 
			
		||||
		validateVUD(t, "AC.PowerFactor", ac0.PowerFactor)
 | 
			
		||||
		validateVUD(t, "AC.ReactivePower", ac0.ReactivePower)
 | 
			
		||||
	})
 | 
			
		||||
 | 
			
		||||
	// Validate DC channel structure (string-keyed map with name field)
 | 
			
		||||
	t.Run("DC_Structure", func(t *testing.T) {
 | 
			
		||||
		if len(inv.DC) == 0 {
 | 
			
		||||
			t.Fatal("DC map is empty")
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// OpenDTU uses string keys: "0", "1", etc.
 | 
			
		||||
		dc0, exists := inv.DC["0"]
 | 
			
		||||
		if !exists {
 | 
			
		||||
			t.Fatal("DC[\"0\"] does not exist - OpenDTU uses string-keyed maps")
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Validate DC name field (from WebApi_ws_live.cpp line 182)
 | 
			
		||||
		if dc0.Name.U == "" {
 | 
			
		||||
			t.Error("DC channel name is empty")
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Validate DC fields (from addField calls)
 | 
			
		||||
		// Lines 184-200: FLD_PDC, FLD_UDC, FLD_IDC, FLD_YD, FLD_YT, FLD_IRR
 | 
			
		||||
		validateVUD(t, "DC.Power", dc0.Power)
 | 
			
		||||
		validateVUD(t, "DC.Voltage", dc0.Voltage)
 | 
			
		||||
		validateVUD(t, "DC.Current", dc0.Current)
 | 
			
		||||
		validateVUD(t, "DC.YieldDay", dc0.YieldDay)
 | 
			
		||||
		validateVUD(t, "DC.YieldTotal", dc0.YieldTotal)
 | 
			
		||||
 | 
			
		||||
		// Irradiation has special max field (line 201-203)
 | 
			
		||||
		if dc0.Irradiation.V < 0 || dc0.Irradiation.V > 100 {
 | 
			
		||||
			t.Errorf("DC.Irradiation.V out of range: %v", dc0.Irradiation.V)
 | 
			
		||||
		}
 | 
			
		||||
		if dc0.Irradiation.Max <= 0 {
 | 
			
		||||
			t.Error("DC.Irradiation.max field missing or invalid")
 | 
			
		||||
		}
 | 
			
		||||
	})
 | 
			
		||||
 | 
			
		||||
	// Validate INV channel structure
 | 
			
		||||
	t.Run("INV_Structure", func(t *testing.T) {
 | 
			
		||||
		if len(inv.INV) == 0 {
 | 
			
		||||
			t.Fatal("INV map is empty")
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// OpenDTU uses string keys: "0"
 | 
			
		||||
		inv0, exists := inv.INV["0"]
 | 
			
		||||
		if !exists {
 | 
			
		||||
			t.Fatal("INV[\"0\"] does not exist - OpenDTU uses string-keyed maps")
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Validate INV fields (from addField calls)
 | 
			
		||||
		// Lines 184-200: FLD_T, FLD_EFF, FLD_PDC (as "Power DC"), FLD_YD, FLD_YT
 | 
			
		||||
		validateVUD(t, "INV.Temperature", inv0.Temperature)
 | 
			
		||||
		validateVUD(t, "INV.Efficiency", inv0.Efficiency)
 | 
			
		||||
		validateVUD(t, "INV.PowerDC", inv0.PowerDC)
 | 
			
		||||
		validateVUD(t, "INV.YieldDay", inv0.YieldDay)
 | 
			
		||||
		validateVUD(t, "INV.YieldTotal", inv0.YieldTotal)
 | 
			
		||||
	})
 | 
			
		||||
 | 
			
		||||
	// Validate events field (from WebApi_ws_live.cpp lines 206-210)
 | 
			
		||||
	t.Run("Events", func(t *testing.T) {
 | 
			
		||||
		// events can be -1 if not available, or >= 0
 | 
			
		||||
		if inv.Events < -1 {
 | 
			
		||||
			t.Errorf("events field has invalid value: %d", inv.Events)
 | 
			
		||||
		}
 | 
			
		||||
	})
 | 
			
		||||
 | 
			
		||||
	// Validate total structure (from generateCommonJsonResponse lines 128-130)
 | 
			
		||||
	t.Run("Total", func(t *testing.T) {
 | 
			
		||||
		validateVUD(t, "Total.Power", liveData.Total.Power)
 | 
			
		||||
		validateVUD(t, "Total.YieldDay", liveData.Total.YieldDay)
 | 
			
		||||
		validateVUD(t, "Total.YieldTotal", liveData.Total.YieldTotal)
 | 
			
		||||
	})
 | 
			
		||||
 | 
			
		||||
	// Validate hints structure (from generateCommonJsonResponse lines 132-138)
 | 
			
		||||
	t.Run("Hints", func(t *testing.T) {
 | 
			
		||||
		// All fields are boolean
 | 
			
		||||
		_ = liveData.Hints.TimeSync
 | 
			
		||||
		_ = liveData.Hints.RadioProblem
 | 
			
		||||
		_ = liveData.Hints.DefaultPassword
 | 
			
		||||
		_ = liveData.Hints.PinMappingIssue
 | 
			
		||||
	})
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// TestOpenDTUCompatibility_StringKeyedMaps verifies that AC/DC/INV are string-keyed maps
 | 
			
		||||
// This is critical because OpenDTU C++ code uses String(channel) conversion (line 224)
 | 
			
		||||
func TestOpenDTUCompatibility_StringKeyedMaps(t *testing.T) {
 | 
			
		||||
	data, err := loadTestData("livedata_producing.json")
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		t.Fatalf("Failed to load test data: %v", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	var liveData LiveData
 | 
			
		||||
	err = json.Unmarshal(data, &liveData)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		t.Fatalf("Failed to unmarshal LiveData: %v", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	inv := liveData.Inverters[0]
 | 
			
		||||
 | 
			
		||||
	// Test that maps use string keys "0", "1", NOT integer indices
 | 
			
		||||
	t.Run("AC_StringKeys", func(t *testing.T) {
 | 
			
		||||
		if _, exists := inv.AC["0"]; !exists {
 | 
			
		||||
			t.Error("AC must use string key \"0\" not integer 0")
 | 
			
		||||
		}
 | 
			
		||||
		// Should NOT be accessible as integer
 | 
			
		||||
		if len(inv.AC) > 0 {
 | 
			
		||||
			// This is correct - we're using map[string]
 | 
			
		||||
			t.Log("AC correctly uses map[string]InverterAC")
 | 
			
		||||
		}
 | 
			
		||||
	})
 | 
			
		||||
 | 
			
		||||
	t.Run("DC_StringKeys", func(t *testing.T) {
 | 
			
		||||
		if _, exists := inv.DC["0"]; !exists {
 | 
			
		||||
			t.Error("DC must use string key \"0\" not integer 0")
 | 
			
		||||
		}
 | 
			
		||||
		if _, exists := inv.DC["1"]; len(inv.DC) > 1 && !exists {
 | 
			
		||||
			t.Error("DC must use string key \"1\" not integer 1")
 | 
			
		||||
		}
 | 
			
		||||
	})
 | 
			
		||||
 | 
			
		||||
	t.Run("INV_StringKeys", func(t *testing.T) {
 | 
			
		||||
		if _, exists := inv.INV["0"]; !exists {
 | 
			
		||||
			t.Error("INV must use string key \"0\" not integer 0")
 | 
			
		||||
		}
 | 
			
		||||
	})
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// TestOpenDTUCompatibility_MultipleInverters validates proper handling of inverter arrays
 | 
			
		||||
// OpenDTU creates an array of inverter objects (WebApi_ws_live.cpp line 95)
 | 
			
		||||
func TestOpenDTUCompatibility_MultipleInverters(t *testing.T) {
 | 
			
		||||
	data, err := loadTestData("livedata_producing.json")
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		t.Fatalf("Failed to load test data: %v", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	var liveData LiveData
 | 
			
		||||
	err = json.Unmarshal(data, &liveData)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		t.Fatalf("Failed to unmarshal LiveData: %v", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if len(liveData.Inverters) < 2 {
 | 
			
		||||
		t.Skip("Test requires at least 2 inverters")
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Verify each inverter has unique serial and proper order
 | 
			
		||||
	t.Run("UniqueSerials", func(t *testing.T) {
 | 
			
		||||
		serials := make(map[string]bool)
 | 
			
		||||
		for _, inv := range liveData.Inverters {
 | 
			
		||||
			if serials[inv.Serial] {
 | 
			
		||||
				t.Errorf("Duplicate serial found: %s", inv.Serial)
 | 
			
		||||
			}
 | 
			
		||||
			serials[inv.Serial] = true
 | 
			
		||||
		}
 | 
			
		||||
	})
 | 
			
		||||
 | 
			
		||||
	t.Run("ProperOrdering", func(t *testing.T) {
 | 
			
		||||
		for i, inv := range liveData.Inverters {
 | 
			
		||||
			if inv.Order != i {
 | 
			
		||||
				t.Logf("Warning: inverter at index %d has order %d (may be intentional)", i, inv.Order)
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
	})
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// TestOpenDTUCompatibility_NightMode validates zero-production scenario
 | 
			
		||||
// Tests that the structure is valid even when inverters are not producing
 | 
			
		||||
func TestOpenDTUCompatibility_NightMode(t *testing.T) {
 | 
			
		||||
	data, err := loadTestData("livedata_night.json")
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		t.Fatalf("Failed to load night test data: %v", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	var liveData LiveData
 | 
			
		||||
	err = json.Unmarshal(data, &liveData)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		t.Fatalf("Failed to unmarshal night LiveData: %v", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if len(liveData.Inverters) == 0 {
 | 
			
		||||
		t.Fatal("Expected at least one inverter in night data")
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	inv := liveData.Inverters[0]
 | 
			
		||||
 | 
			
		||||
	// During night, inverters should not be producing
 | 
			
		||||
	if inv.Producing {
 | 
			
		||||
		t.Error("Inverter should not be producing at night")
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Data age should be higher (older data)
 | 
			
		||||
	if inv.DataAge == 0 {
 | 
			
		||||
		t.Error("Expected non-zero data_age at night (stale data)")
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Total power should be zero or near-zero
 | 
			
		||||
	if liveData.Total.Power.V > 1.0 {
 | 
			
		||||
		t.Errorf("Expected near-zero total power at night, got %v W", liveData.Total.Power.V)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Structure should still be valid
 | 
			
		||||
	if len(inv.AC) == 0 {
 | 
			
		||||
		t.Error("AC structure should exist even at night")
 | 
			
		||||
	}
 | 
			
		||||
	if len(inv.DC) == 0 {
 | 
			
		||||
		t.Error("DC structure should exist even at night")
 | 
			
		||||
	}
 | 
			
		||||
	if len(inv.INV) == 0 {
 | 
			
		||||
		t.Error("INV structure should exist even at night")
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// TestOpenDTUCompatibility_FieldNames validates exact field naming from OpenDTU
 | 
			
		||||
// Field names come from getChannelFieldName in OpenDTU C++ code
 | 
			
		||||
func TestOpenDTUCompatibility_FieldNames(t *testing.T) {
 | 
			
		||||
	data, err := loadTestData("livedata_producing.json")
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		t.Fatalf("Failed to load test data: %v", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Parse as generic JSON to inspect field names
 | 
			
		||||
	var jsonData map[string]interface{}
 | 
			
		||||
	err = json.Unmarshal(data, &jsonData)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		t.Fatalf("Failed to unmarshal as generic JSON: %v", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	inverters := jsonData["inverters"].([]interface{})
 | 
			
		||||
	inv := inverters[0].(map[string]interface{})
 | 
			
		||||
 | 
			
		||||
	// Check root inverter field names (exact names from C++ code)
 | 
			
		||||
	expectedRootFields := []string{
 | 
			
		||||
		"serial", "name", "order", "data_age", "data_age_ms",
 | 
			
		||||
		"poll_enabled", "reachable", "producing",
 | 
			
		||||
		"limit_relative", "limit_absolute", "events",
 | 
			
		||||
		"AC", "DC", "INV", "radio_stats",
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	for _, field := range expectedRootFields {
 | 
			
		||||
		if _, exists := inv[field]; !exists {
 | 
			
		||||
			t.Errorf("Missing expected root field: %s", field)
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Check radio_stats field names
 | 
			
		||||
	radioStats := inv["radio_stats"].(map[string]interface{})
 | 
			
		||||
	expectedRadioFields := []string{
 | 
			
		||||
		"tx_request", "tx_re_request", "rx_success",
 | 
			
		||||
		"rx_fail_nothing", "rx_fail_partial", "rx_fail_corrupt", "rssi",
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	for _, field := range expectedRadioFields {
 | 
			
		||||
		if _, exists := radioStats[field]; !exists {
 | 
			
		||||
			t.Errorf("Missing expected radio_stats field: %s", field)
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Check hints field names
 | 
			
		||||
	hints := jsonData["hints"].(map[string]interface{})
 | 
			
		||||
	expectedHintFields := []string{
 | 
			
		||||
		"time_sync", "radio_problem", "default_password", "pin_mapping_issue",
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	for _, field := range expectedHintFields {
 | 
			
		||||
		if _, exists := hints[field]; !exists {
 | 
			
		||||
			t.Errorf("Missing expected hints field: %s", field)
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// TestOpenDTUCompatibility_WarningFixture validates branches only hit when OpenDTU
 | 
			
		||||
// cannot determine inverter max power, event log, or when hints flag issues.
 | 
			
		||||
func TestOpenDTUCompatibility_WarningFixture(t *testing.T) {
 | 
			
		||||
	data, err := loadTestData("livedata_warnings.json")
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		t.Fatalf("Failed to load warning test data: %v", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	var liveData LiveData
 | 
			
		||||
	if err := json.Unmarshal(data, &liveData); err != nil {
 | 
			
		||||
		t.Fatalf("Failed to unmarshal warning fixture: %v", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if len(liveData.Inverters) != 1 {
 | 
			
		||||
		t.Fatalf("Expected 1 inverter in warning fixture, got %d", len(liveData.Inverters))
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	inv := liveData.Inverters[0]
 | 
			
		||||
	if inv.LimitAbsolute != -1 {
 | 
			
		||||
		t.Errorf("Expected LimitAbsolute fallback -1, got %v", inv.LimitAbsolute)
 | 
			
		||||
	}
 | 
			
		||||
	if inv.Events != -1 {
 | 
			
		||||
		t.Errorf("Expected Events=-1 when event log channel missing, got %v", inv.Events)
 | 
			
		||||
	}
 | 
			
		||||
	if !inv.PollEnabled {
 | 
			
		||||
		t.Errorf("Expected PollEnabled=true for warning fixture")
 | 
			
		||||
	}
 | 
			
		||||
	if inv.Reachable {
 | 
			
		||||
		t.Errorf("Expected Reachable=false for warning fixture")
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if liveData.Hints.TimeSync {
 | 
			
		||||
		t.Errorf("Expected TimeSync=false, got %v", liveData.Hints.TimeSync)
 | 
			
		||||
	}
 | 
			
		||||
	if !liveData.Hints.RadioProblem {
 | 
			
		||||
		t.Errorf("Expected RadioProblem=true, got %v", liveData.Hints.RadioProblem)
 | 
			
		||||
	}
 | 
			
		||||
	if !liveData.Hints.DefaultPassword {
 | 
			
		||||
		t.Errorf("Expected DefaultPassword=true, got %v", liveData.Hints.DefaultPassword)
 | 
			
		||||
	}
 | 
			
		||||
	if !liveData.Hints.PinMappingIssue {
 | 
			
		||||
		t.Errorf("Expected PinMappingIssue=true, got %v", liveData.Hints.PinMappingIssue)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	var generic map[string]interface{}
 | 
			
		||||
	if err := json.Unmarshal(data, &generic); err != nil {
 | 
			
		||||
		t.Fatalf("Failed to unmarshal warning fixture generically: %v", err)
 | 
			
		||||
	}
 | 
			
		||||
	invertersRaw, ok := generic["inverters"].([]interface{})
 | 
			
		||||
	if !ok || len(invertersRaw) == 0 {
 | 
			
		||||
		t.Fatalf("Generic inverter array missing")
 | 
			
		||||
	}
 | 
			
		||||
	invMap, ok := invertersRaw[0].(map[string]interface{})
 | 
			
		||||
	if !ok {
 | 
			
		||||
		t.Fatalf("Generic inverter map missing")
 | 
			
		||||
	}
 | 
			
		||||
	if _, exists := invMap["BAT"]; !exists {
 | 
			
		||||
		t.Errorf("Expected additional channel map 'BAT' to be present")
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Helper function to validate VUD structure
 | 
			
		||||
func validateVUD(t *testing.T, fieldName string, vud VUD) {
 | 
			
		||||
	t.Helper()
 | 
			
		||||
	// Value can be any number (including 0 or negative)
 | 
			
		||||
	_ = vud.V
 | 
			
		||||
	// Unit can be empty string for dimensionless values
 | 
			
		||||
	_ = vud.U
 | 
			
		||||
	// Decimals should be non-negative
 | 
			
		||||
	if vud.D < 0 {
 | 
			
		||||
		t.Errorf("%s: decimals (d) should be non-negative, got %d", fieldName, vud.D)
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// TestOpenDTUCompatibility_EventsValue tests the events field edge cases
 | 
			
		||||
// From WebApi_ws_live.cpp lines 206-210:
 | 
			
		||||
// - Returns event count if available
 | 
			
		||||
// - Returns -1 if not available
 | 
			
		||||
func TestOpenDTUCompatibility_EventsValue(t *testing.T) {
 | 
			
		||||
	testCases := []struct {
 | 
			
		||||
		name          string
 | 
			
		||||
		eventsValue   int
 | 
			
		||||
		expectedValid bool
 | 
			
		||||
	}{
 | 
			
		||||
		{"No events", 0, true},
 | 
			
		||||
		{"Some events", 5, true},
 | 
			
		||||
		{"Many events", 100, true},
 | 
			
		||||
		{"Not available", -1, true},
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	for _, tc := range testCases {
 | 
			
		||||
		t.Run(tc.name, func(t *testing.T) {
 | 
			
		||||
			// Parse test data and modify events value
 | 
			
		||||
			data, err := loadTestData("livedata_producing.json")
 | 
			
		||||
			if err != nil {
 | 
			
		||||
				t.Fatalf("Failed to load test data: %v", err)
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			// Parse, modify, and re-encode
 | 
			
		||||
			var liveData LiveData
 | 
			
		||||
			err = json.Unmarshal(data, &liveData)
 | 
			
		||||
			if err != nil {
 | 
			
		||||
				t.Fatalf("Failed to unmarshal: %v", err)
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			liveData.Inverters[0].Events = tc.eventsValue
 | 
			
		||||
 | 
			
		||||
			// Re-marshal and unmarshal to test the value
 | 
			
		||||
			modifiedData, _ := json.Marshal(liveData)
 | 
			
		||||
			var testData LiveData
 | 
			
		||||
			err = json.Unmarshal(modifiedData, &testData)
 | 
			
		||||
			if err != nil {
 | 
			
		||||
				t.Fatalf("Failed to unmarshal modified data: %v", err)
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			if testData.Inverters[0].Events != tc.eventsValue {
 | 
			
		||||
				t.Errorf("Expected events=%d, got %d", tc.eventsValue, testData.Inverters[0].Events)
 | 
			
		||||
			}
 | 
			
		||||
		})
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										23
									
								
								testdata/events_response.json
									
										
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								testdata/events_response.json
									
										
									
									
										vendored
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,23 @@
 | 
			
		|||
{
 | 
			
		||||
  "count": 3,
 | 
			
		||||
  "events": [
 | 
			
		||||
    {
 | 
			
		||||
      "message_id": 1,
 | 
			
		||||
      "message": "Inverter start",
 | 
			
		||||
      "start_time": 1634567890,
 | 
			
		||||
      "end_time": 1634567950
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      "message_id": 2,
 | 
			
		||||
      "message": "Grid fault",
 | 
			
		||||
      "start_time": 1634568000,
 | 
			
		||||
      "end_time": 1634568120
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      "message_id": 3,
 | 
			
		||||
      "message": "Communication error",
 | 
			
		||||
      "start_time": 1634568200,
 | 
			
		||||
      "end_time": 0
 | 
			
		||||
    }
 | 
			
		||||
  ]
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										112
									
								
								testdata/livedata_night.json
									
										
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										112
									
								
								testdata/livedata_night.json
									
										
									
									
										vendored
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,112 @@
 | 
			
		|||
{
 | 
			
		||||
  "inverters": [
 | 
			
		||||
    {
 | 
			
		||||
  "serial": "987654321012",
 | 
			
		||||
  "name": "Dummy 02",
 | 
			
		||||
      "order": 1,
 | 
			
		||||
      "data_age": 20840,
 | 
			
		||||
      "data_age_ms": 20840477,
 | 
			
		||||
      "poll_enabled": false,
 | 
			
		||||
      "reachable": false,
 | 
			
		||||
      "producing": false,
 | 
			
		||||
      "limit_relative": 100,
 | 
			
		||||
      "limit_absolute": 2250,
 | 
			
		||||
      "events": 0,
 | 
			
		||||
      "AC": {
 | 
			
		||||
        "0": {
 | 
			
		||||
          "Power": {"v": 0, "u": "W", "d": 1},
 | 
			
		||||
          "Voltage": {"v": 0, "u": "V", "d": 1},
 | 
			
		||||
          "Current": {"v": 0, "u": "A", "d": 2},
 | 
			
		||||
          "Frequency": {"v": 0, "u": "Hz", "d": 2},
 | 
			
		||||
          "PowerFactor": {"v": 0, "u": "", "d": 3},
 | 
			
		||||
          "ReactivePower": {"v": 0, "u": "var", "d": 1}
 | 
			
		||||
        }
 | 
			
		||||
      },
 | 
			
		||||
      "DC": {
 | 
			
		||||
        "0": {
 | 
			
		||||
          "name": {"u": "X1A DMY000000000000001ZX"},
 | 
			
		||||
          "Power": {"v": 0, "u": "W", "d": 1},
 | 
			
		||||
          "Voltage": {"v": 0, "u": "V", "d": 1},
 | 
			
		||||
          "Current": {"v": 0, "u": "A", "d": 2},
 | 
			
		||||
          "YieldDay": {"v": 0, "u": "Wh", "d": 0},
 | 
			
		||||
          "YieldTotal": {"v": 768.194, "u": "kWh", "d": 3},
 | 
			
		||||
          "Irradiation": {"v": 0, "u": "%", "d": 3, "max": 440}
 | 
			
		||||
        },
 | 
			
		||||
        "1": {
 | 
			
		||||
          "name": {"u": "X2B DMY000000000000002ZX"},
 | 
			
		||||
          "Power": {"v": 0, "u": "W", "d": 1},
 | 
			
		||||
          "Voltage": {"v": 0, "u": "V", "d": 1},
 | 
			
		||||
          "Current": {"v": 0, "u": "A", "d": 2},
 | 
			
		||||
          "YieldDay": {"v": 0, "u": "Wh", "d": 0},
 | 
			
		||||
          "YieldTotal": {"v": 723.244, "u": "kWh", "d": 3},
 | 
			
		||||
          "Irradiation": {"v": 0, "u": "%", "d": 3, "max": 440}
 | 
			
		||||
        },
 | 
			
		||||
        "2": {
 | 
			
		||||
          "name": {"u": "X3C DMY000000000000003ZX"},
 | 
			
		||||
          "Power": {"v": 0, "u": "W", "d": 1},
 | 
			
		||||
          "Voltage": {"v": 0, "u": "V", "d": 1},
 | 
			
		||||
          "Current": {"v": 0, "u": "A", "d": 2},
 | 
			
		||||
          "YieldDay": {"v": 0, "u": "Wh", "d": 0},
 | 
			
		||||
          "YieldTotal": {"v": 603.444, "u": "kWh", "d": 3},
 | 
			
		||||
          "Irradiation": {"v": 0, "u": "%", "d": 3, "max": 440}
 | 
			
		||||
        },
 | 
			
		||||
        "3": {
 | 
			
		||||
          "name": {"u": "X4D DMY000000000000004ZX"},
 | 
			
		||||
          "Power": {"v": 0, "u": "W", "d": 1},
 | 
			
		||||
          "Voltage": {"v": 0, "u": "V", "d": 1},
 | 
			
		||||
          "Current": {"v": 0, "u": "A", "d": 2},
 | 
			
		||||
          "YieldDay": {"v": 0, "u": "Wh", "d": 0},
 | 
			
		||||
          "YieldTotal": {"v": 633.541, "u": "kWh", "d": 3},
 | 
			
		||||
          "Irradiation": {"v": 0, "u": "%", "d": 3, "max": 440}
 | 
			
		||||
        },
 | 
			
		||||
        "4": {
 | 
			
		||||
          "name": {"u": "X5E DMY000000000000005ZX"},
 | 
			
		||||
          "Power": {"v": 0, "u": "W", "d": 1},
 | 
			
		||||
          "Voltage": {"v": 0, "u": "V", "d": 1},
 | 
			
		||||
          "Current": {"v": 0, "u": "A", "d": 2},
 | 
			
		||||
          "YieldDay": {"v": 0, "u": "Wh", "d": 0},
 | 
			
		||||
          "YieldTotal": {"v": 725.182, "u": "kWh", "d": 3},
 | 
			
		||||
          "Irradiation": {"v": 0, "u": "%", "d": 3, "max": 440}
 | 
			
		||||
        },
 | 
			
		||||
        "5": {
 | 
			
		||||
          "name": {"u": "X6F DMY000000000000006ZX"},
 | 
			
		||||
          "Power": {"v": 0, "u": "W", "d": 1},
 | 
			
		||||
          "Voltage": {"v": 0, "u": "V", "d": 1},
 | 
			
		||||
          "Current": {"v": 0, "u": "A", "d": 2},
 | 
			
		||||
          "YieldDay": {"v": 0, "u": "Wh", "d": 0},
 | 
			
		||||
          "YieldTotal": {"v": 773.515, "u": "kWh", "d": 3},
 | 
			
		||||
          "Irradiation": {"v": 0, "u": "%", "d": 3, "max": 440}
 | 
			
		||||
        }
 | 
			
		||||
      },
 | 
			
		||||
      "INV": {
 | 
			
		||||
        "0": {
 | 
			
		||||
          "Power DC": {"v": 0, "u": "W", "d": 1},
 | 
			
		||||
          "YieldDay": {"v": 0, "u": "Wh", "d": 0},
 | 
			
		||||
          "YieldTotal": {"v": 4227.12, "u": "kWh", "d": 3},
 | 
			
		||||
          "Temperature": {"v": 0, "u": "°C", "d": 1},
 | 
			
		||||
          "Efficiency": {"v": 0, "u": "%", "d": 3}
 | 
			
		||||
        }
 | 
			
		||||
      },
 | 
			
		||||
      "radio_stats": {
 | 
			
		||||
        "tx_request": 147,
 | 
			
		||||
        "tx_re_request": 0,
 | 
			
		||||
        "rx_success": 0,
 | 
			
		||||
        "rx_fail_nothing": 147,
 | 
			
		||||
        "rx_fail_partial": 0,
 | 
			
		||||
        "rx_fail_corrupt": 0,
 | 
			
		||||
        "rssi": -62
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  ],
 | 
			
		||||
  "total": {
 | 
			
		||||
    "Power": {"v": 0, "u": "W", "d": 0},
 | 
			
		||||
    "YieldDay": {"v": 0, "u": "Wh", "d": 0},
 | 
			
		||||
    "YieldTotal": {"v": 16253.99, "u": "kWh", "d": 3}
 | 
			
		||||
  },
 | 
			
		||||
  "hints": {
 | 
			
		||||
    "time_sync": false,
 | 
			
		||||
    "radio_problem": false,
 | 
			
		||||
    "default_password": false,
 | 
			
		||||
    "pin_mapping_issue": false
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										137
									
								
								testdata/livedata_producing.json
									
										
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										137
									
								
								testdata/livedata_producing.json
									
										
									
									
										vendored
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,137 @@
 | 
			
		|||
{
 | 
			
		||||
  "inverters": [
 | 
			
		||||
    {
 | 
			
		||||
      "serial": "114173123456",
 | 
			
		||||
      "name": "Hoymiles HM-800",
 | 
			
		||||
      "order": 0,
 | 
			
		||||
      "data_age": 0,
 | 
			
		||||
      "data_age_ms": 124,
 | 
			
		||||
      "poll_enabled": true,
 | 
			
		||||
      "reachable": true,
 | 
			
		||||
      "producing": true,
 | 
			
		||||
      "limit_relative": 100.0,
 | 
			
		||||
      "limit_absolute": 800.0,
 | 
			
		||||
      "events": 2,
 | 
			
		||||
      "AC": {
 | 
			
		||||
        "0": {
 | 
			
		||||
          "Power": {"v": 734.2, "u": "W", "d": 1},
 | 
			
		||||
          "Voltage": {"v": 230.1, "u": "V", "d": 1},
 | 
			
		||||
          "Current": {"v": 3.19, "u": "A", "d": 2},
 | 
			
		||||
          "Frequency": {"v": 50.02, "u": "Hz", "d": 2},
 | 
			
		||||
          "PowerFactor": {"v": 1.0, "u": "", "d": 3},
 | 
			
		||||
          "ReactivePower": {"v": 0.0, "u": "var", "d": 1}
 | 
			
		||||
        }
 | 
			
		||||
      },
 | 
			
		||||
      "DC": {
 | 
			
		||||
        "0": {
 | 
			
		||||
          "name": {"u": "String 1"},
 | 
			
		||||
          "Power": {"v": 381.3, "u": "W", "d": 1},
 | 
			
		||||
          "Voltage": {"v": 36.7, "u": "V", "d": 1},
 | 
			
		||||
          "Current": {"v": 10.39, "u": "A", "d": 2},
 | 
			
		||||
          "YieldDay": {"v": 3847, "u": "Wh", "d": 0},
 | 
			
		||||
          "YieldTotal": {"v": 1247.531, "u": "kWh", "d": 3},
 | 
			
		||||
          "Irradiation": {"v": 87.3, "u": "%", "d": 3, "max": 440}
 | 
			
		||||
        },
 | 
			
		||||
        "1": {
 | 
			
		||||
          "name": {"u": "String 2"},
 | 
			
		||||
          "Power": {"v": 367.8, "u": "W", "d": 1},
 | 
			
		||||
          "Voltage": {"v": 35.2, "u": "V", "d": 1},
 | 
			
		||||
          "Current": {"v": 10.45, "u": "A", "d": 2},
 | 
			
		||||
          "YieldDay": {"v": 3712, "u": "Wh", "d": 0},
 | 
			
		||||
          "YieldTotal": {"v": 1203.847, "u": "kWh", "d": 3},
 | 
			
		||||
          "Irradiation": {"v": 84.2, "u": "%", "d": 3, "max": 440}
 | 
			
		||||
        }
 | 
			
		||||
      },
 | 
			
		||||
      "INV": {
 | 
			
		||||
        "0": {
 | 
			
		||||
          "Temperature": {"v": 34.2, "u": "°C", "d": 1},
 | 
			
		||||
          "Efficiency": {"v": 97.8, "u": "%", "d": 3},
 | 
			
		||||
          "Power DC": {"v": 749.1, "u": "W", "d": 1},
 | 
			
		||||
          "YieldDay": {"v": 7559, "u": "Wh", "d": 0},
 | 
			
		||||
          "YieldTotal": {"v": 2451.378, "u": "kWh", "d": 3}
 | 
			
		||||
        }
 | 
			
		||||
      },
 | 
			
		||||
      "radio_stats": {
 | 
			
		||||
        "tx_request": 12345,
 | 
			
		||||
        "tx_re_request": 234,
 | 
			
		||||
        "rx_success": 12000,
 | 
			
		||||
        "rx_fail_nothing": 50,
 | 
			
		||||
        "rx_fail_partial": 30,
 | 
			
		||||
        "rx_fail_corrupt": 21,
 | 
			
		||||
        "rssi": -65.5
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      "serial": "114173654321",
 | 
			
		||||
      "name": "Hoymiles HM-600",
 | 
			
		||||
      "order": 1,
 | 
			
		||||
      "data_age": 0,
 | 
			
		||||
      "data_age_ms": 235,
 | 
			
		||||
      "poll_enabled": true,
 | 
			
		||||
      "reachable": true,
 | 
			
		||||
      "producing": true,
 | 
			
		||||
      "limit_relative": 100.0,
 | 
			
		||||
      "limit_absolute": 600.0,
 | 
			
		||||
      "events": 0,
 | 
			
		||||
      "AC": {
 | 
			
		||||
        "0": {
 | 
			
		||||
          "Power": {"v": 542.7, "u": "W", "d": 1},
 | 
			
		||||
          "Voltage": {"v": 229.8, "u": "V", "d": 1},
 | 
			
		||||
          "Current": {"v": 2.36, "u": "A", "d": 2},
 | 
			
		||||
          "Frequency": {"v": 50.01, "u": "Hz", "d": 2},
 | 
			
		||||
          "PowerFactor": {"v": 1.0, "u": "", "d": 3},
 | 
			
		||||
          "ReactivePower": {"v": 0.0, "u": "var", "d": 1}
 | 
			
		||||
        }
 | 
			
		||||
      },
 | 
			
		||||
      "DC": {
 | 
			
		||||
        "0": {
 | 
			
		||||
          "name": {"u": "String 1"},
 | 
			
		||||
          "Power": {"v": 281.4, "u": "W", "d": 1},
 | 
			
		||||
          "Voltage": {"v": 32.1, "u": "V", "d": 1},
 | 
			
		||||
          "Current": {"v": 8.77, "u": "A", "d": 2},
 | 
			
		||||
          "YieldDay": {"v": 2834, "u": "Wh", "d": 0},
 | 
			
		||||
          "YieldTotal": {"v": 923.156, "u": "kWh", "d": 3},
 | 
			
		||||
          "Irradiation": {"v": 81.4, "u": "%", "d": 3, "max": 440}
 | 
			
		||||
        },
 | 
			
		||||
        "1": {
 | 
			
		||||
          "name": {"u": "String 2"},
 | 
			
		||||
          "Power": {"v": 273.9, "u": "W", "d": 1},
 | 
			
		||||
          "Voltage": {"v": 31.8, "u": "V", "d": 1},
 | 
			
		||||
          "Current": {"v": 8.61, "u": "A", "d": 2},
 | 
			
		||||
          "YieldDay": {"v": 2756, "u": "Wh", "d": 0},
 | 
			
		||||
          "YieldTotal": {"v": 897.423, "u": "kWh", "d": 3},
 | 
			
		||||
          "Irradiation": {"v": 79.2, "u": "%", "d": 3, "max": 440}
 | 
			
		||||
        }
 | 
			
		||||
      },
 | 
			
		||||
      "INV": {
 | 
			
		||||
        "0": {
 | 
			
		||||
          "Temperature": {"v": 32.8, "u": "°C", "d": 1},
 | 
			
		||||
          "Efficiency": {"v": 97.2, "u": "%", "d": 3},
 | 
			
		||||
          "Power DC": {"v": 555.3, "u": "W", "d": 1},
 | 
			
		||||
          "YieldDay": {"v": 5590, "u": "Wh", "d": 0},
 | 
			
		||||
          "YieldTotal": {"v": 1820.579, "u": "kWh", "d": 3}
 | 
			
		||||
        }
 | 
			
		||||
      },
 | 
			
		||||
      "radio_stats": {
 | 
			
		||||
        "tx_request": 8765,
 | 
			
		||||
        "tx_re_request": 123,
 | 
			
		||||
        "rx_success": 8600,
 | 
			
		||||
        "rx_fail_nothing": 20,
 | 
			
		||||
        "rx_fail_partial": 15,
 | 
			
		||||
        "rx_fail_corrupt": 7,
 | 
			
		||||
        "rssi": -72.3
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  ],
 | 
			
		||||
  "total": {
 | 
			
		||||
    "Power": {"v": 1276.9, "u": "W", "d": 1},
 | 
			
		||||
    "YieldDay": {"v": 13149, "u": "Wh", "d": 0},
 | 
			
		||||
    "YieldTotal": {"v": 4271.957, "u": "kWh", "d": 3}
 | 
			
		||||
  },
 | 
			
		||||
  "hints": {
 | 
			
		||||
    "time_sync": true,
 | 
			
		||||
    "radio_problem": false,
 | 
			
		||||
    "default_password": false,
 | 
			
		||||
    "pin_mapping_issue": false
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										73
									
								
								testdata/livedata_warnings.json
									
										
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										73
									
								
								testdata/livedata_warnings.json
									
										
									
									
										vendored
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,73 @@
 | 
			
		|||
{
 | 
			
		||||
  "inverters": [
 | 
			
		||||
    {
 | 
			
		||||
      "serial": "555444333222",
 | 
			
		||||
      "name": "Dummy Warning",
 | 
			
		||||
      "order": 2,
 | 
			
		||||
      "data_age": 45,
 | 
			
		||||
      "data_age_ms": 45231,
 | 
			
		||||
      "poll_enabled": true,
 | 
			
		||||
      "reachable": false,
 | 
			
		||||
      "producing": false,
 | 
			
		||||
      "limit_relative": 100.0,
 | 
			
		||||
      "limit_absolute": -1,
 | 
			
		||||
      "events": -1,
 | 
			
		||||
      "AC": {
 | 
			
		||||
        "0": {
 | 
			
		||||
          "Power": {"v": 0, "u": "W", "d": 1},
 | 
			
		||||
          "Voltage": {"v": 229.4, "u": "V", "d": 1},
 | 
			
		||||
          "Current": {"v": 0, "u": "A", "d": 2},
 | 
			
		||||
          "Frequency": {"v": 50.01, "u": "Hz", "d": 2},
 | 
			
		||||
          "PowerFactor": {"v": 0.0, "u": "", "d": 3},
 | 
			
		||||
          "ReactivePower": {"v": 0.0, "u": "var", "d": 1}
 | 
			
		||||
        }
 | 
			
		||||
      },
 | 
			
		||||
      "DC": {
 | 
			
		||||
        "0": {
 | 
			
		||||
          "name": {"u": "X7G DMY000000000000007ZX"},
 | 
			
		||||
          "Power": {"v": 0, "u": "W", "d": 1},
 | 
			
		||||
          "Voltage": {"v": 0.0, "u": "V", "d": 1},
 | 
			
		||||
          "Current": {"v": 0.0, "u": "A", "d": 2},
 | 
			
		||||
          "YieldDay": {"v": 0, "u": "Wh", "d": 0},
 | 
			
		||||
          "YieldTotal": {"v": 123.456, "u": "kWh", "d": 3},
 | 
			
		||||
          "Irradiation": {"v": 0.0, "u": "%", "d": 3, "max": 350}
 | 
			
		||||
        }
 | 
			
		||||
      },
 | 
			
		||||
      "INV": {
 | 
			
		||||
        "0": {
 | 
			
		||||
          "Power DC": {"v": 0.0, "u": "W", "d": 1},
 | 
			
		||||
          "YieldDay": {"v": 0, "u": "Wh", "d": 0},
 | 
			
		||||
          "YieldTotal": {"v": 222.222, "u": "kWh", "d": 3},
 | 
			
		||||
          "Temperature": {"v": 18.4, "u": "°C", "d": 1},
 | 
			
		||||
          "Efficiency": {"v": 0.0, "u": "%", "d": 3}
 | 
			
		||||
        }
 | 
			
		||||
      },
 | 
			
		||||
      "BAT": {
 | 
			
		||||
        "0": {
 | 
			
		||||
          "Power": {"v": -120.5, "u": "W", "d": 1},
 | 
			
		||||
          "StateOfCharge": {"v": 64.2, "u": "%", "d": 1}
 | 
			
		||||
        }
 | 
			
		||||
      },
 | 
			
		||||
      "radio_stats": {
 | 
			
		||||
        "tx_request": 0,
 | 
			
		||||
        "tx_re_request": 0,
 | 
			
		||||
        "rx_success": 0,
 | 
			
		||||
        "rx_fail_nothing": 12,
 | 
			
		||||
        "rx_fail_partial": 0,
 | 
			
		||||
        "rx_fail_corrupt": 0,
 | 
			
		||||
        "rssi": -80.0
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  ],
 | 
			
		||||
  "total": {
 | 
			
		||||
    "Power": {"v": 0.0, "u": "W", "d": 1},
 | 
			
		||||
    "YieldDay": {"v": 0, "u": "Wh", "d": 0},
 | 
			
		||||
    "YieldTotal": {"v": 222.222, "u": "kWh", "d": 3}
 | 
			
		||||
  },
 | 
			
		||||
  "hints": {
 | 
			
		||||
    "time_sync": false,
 | 
			
		||||
    "radio_problem": true,
 | 
			
		||||
    "default_password": true,
 | 
			
		||||
    "pin_mapping_issue": true
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										10
									
								
								testdata/test_config.json
									
										
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								testdata/test_config.json
									
										
									
									
										vendored
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,10 @@
 | 
			
		|||
{
 | 
			
		||||
  "db": "postgres://user:password@localhost:5432/opendtu",
 | 
			
		||||
  "opendtu_address": "192.168.1.100",
 | 
			
		||||
  "opendtu_auth": true,
 | 
			
		||||
  "opendtu_username": "admin",
 | 
			
		||||
  "opendtu_password": "secret123",
 | 
			
		||||
  "timescaledb": true,
 | 
			
		||||
  "tz": "Europe/Amsterdam",
 | 
			
		||||
  "log_level": "INFO"
 | 
			
		||||
}
 | 
			
		||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue