opendtu-logger/main_persistence_test.go

436 lines
12 KiB
Go

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)
}
}