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