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 (
|
require (
|
||||||
|
github.com/DATA-DOG/go-sqlmock v1.5.2 // indirect
|
||||||
github.com/mfridman/interpolate v0.0.2 // indirect
|
github.com/mfridman/interpolate v0.0.2 // indirect
|
||||||
github.com/sethvargo/go-retry v0.3.0 // indirect
|
github.com/sethvargo/go-retry v0.3.0 // indirect
|
||||||
go.uber.org/multierr v1.11.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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
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=
|
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/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 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
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 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||||
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
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