Files
HeatGuard/internal/store/store_test.go
vikingowl 1c9db02334 feat: add web UI with full CRUD setup page
Add server-side rendered setup UI accessible via `heatwave web`.
The dashboard is now re-rendered per request and includes a nav bar
linking to the new /setup page. Setup provides full CRUD for profiles,
rooms, devices, occupants, AC units (with room assignment), scenario
toggles, and forecast fetching — all via POST/redirect/GET forms.

- Add ShowNav field to DashboardData for conditional nav bar
- Extract fetchForecastForProfile() for reuse by web handler
- Create setup.html.tmpl with Tailwind-styled entity sections
- Create web_handlers.go with 15 route handlers and flash cookies
- Switch web.go from pre-rendered to per-request dashboard rendering
- Graceful dashboard fallback when no forecast data exists
2026-02-09 10:39:00 +01:00

497 lines
12 KiB
Go

package store
import (
"testing"
"time"
)
func newTestStore(t *testing.T) *Store {
t.Helper()
s, err := New(":memory:")
if err != nil {
t.Fatalf("failed to create test store: %v", err)
}
t.Cleanup(func() { s.Close() })
return s
}
func TestNewStore(t *testing.T) {
s := newTestStore(t)
if s == nil {
t.Fatal("store is nil")
}
}
// --- Profile CRUD ---
func TestProfileCRUD(t *testing.T) {
s := newTestStore(t)
p, err := s.CreateProfile("home", 52.52, 13.41, "Europe/Berlin")
if err != nil {
t.Fatalf("CreateProfile: %v", err)
}
if p.Name != "home" || p.Latitude != 52.52 || p.Longitude != 13.41 {
t.Errorf("unexpected profile: %+v", p)
}
got, err := s.GetProfile(p.ID)
if err != nil {
t.Fatalf("GetProfile: %v", err)
}
if got.Name != "home" {
t.Errorf("GetProfile name = %s, want home", got.Name)
}
gotByName, err := s.GetProfileByName("home")
if err != nil {
t.Fatalf("GetProfileByName: %v", err)
}
if gotByName.ID != p.ID {
t.Errorf("GetProfileByName ID = %d, want %d", gotByName.ID, p.ID)
}
if err := s.UpdateProfile(p.ID, "name", "office"); err != nil {
t.Fatalf("UpdateProfile: %v", err)
}
updated, _ := s.GetProfile(p.ID)
if updated.Name != "office" {
t.Errorf("updated name = %s, want office", updated.Name)
}
profiles, err := s.ListProfiles()
if err != nil {
t.Fatalf("ListProfiles: %v", err)
}
if len(profiles) != 1 {
t.Errorf("ListProfiles len = %d, want 1", len(profiles))
}
if err := s.DeleteProfile(p.ID); err != nil {
t.Fatalf("DeleteProfile: %v", err)
}
_, err = s.GetProfile(p.ID)
if err == nil {
t.Error("expected error after delete")
}
}
func TestProfileDuplicateName(t *testing.T) {
s := newTestStore(t)
_, err := s.CreateProfile("home", 52.52, 13.41, "")
if err != nil {
t.Fatal(err)
}
_, err = s.CreateProfile("home", 48.13, 11.58, "")
if err == nil {
t.Error("expected error for duplicate name")
}
}
func TestProfileInvalidField(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProfile("home", 52.52, 13.41, "")
err := s.UpdateProfile(p.ID, "nonexistent", "value")
if err == nil {
t.Error("expected error for invalid field")
}
}
// --- Room CRUD ---
func TestRoomCRUD(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProfile("home", 52.52, 13.41, "")
r, err := s.CreateRoom(p.ID, "office", 15, 3, "S", "shutters", 0.3, "natural", "average", DefaultRoomParams())
if err != nil {
t.Fatalf("CreateRoom: %v", err)
}
if r.Name != "office" || r.AreaSqm != 15 || r.Orientation != "S" || r.ShadingFactor != 0.3 {
t.Errorf("unexpected room: %+v", r)
}
rooms, err := s.ListRooms(p.ID)
if err != nil {
t.Fatalf("ListRooms: %v", err)
}
if len(rooms) != 1 {
t.Errorf("ListRooms len = %d, want 1", len(rooms))
}
if err := s.UpdateRoom(r.ID, "name", "bedroom"); err != nil {
t.Fatalf("UpdateRoom: %v", err)
}
updated, _ := s.GetRoom(r.ID)
if updated.Name != "bedroom" {
t.Errorf("updated name = %s, want bedroom", updated.Name)
}
if err := s.DeleteRoom(r.ID); err != nil {
t.Fatalf("DeleteRoom: %v", err)
}
_, err = s.GetRoom(r.ID)
if err == nil {
t.Error("expected error after delete")
}
}
func TestRoomPhysicsFields(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProfile("home", 52.52, 13.41, "")
params := RoomParams{
CeilingHeightM: 3.0,
VentilationACH: 1.5,
WindowFraction: 0.20,
SHGC: 0.45,
}
r, err := s.CreateRoom(p.ID, "office", 15, 3, "S", "shutters", 0.3, "natural", "average", params)
if err != nil {
t.Fatalf("CreateRoom: %v", err)
}
if r.CeilingHeightM != 3.0 {
t.Errorf("CeilingHeightM = %v, want 3.0", r.CeilingHeightM)
}
if r.VentilationACH != 1.5 {
t.Errorf("VentilationACH = %v, want 1.5", r.VentilationACH)
}
if r.WindowFraction != 0.20 {
t.Errorf("WindowFraction = %v, want 0.20", r.WindowFraction)
}
if r.SHGC != 0.45 {
t.Errorf("SHGC = %v, want 0.45", r.SHGC)
}
}
func TestRoomACCapacity(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProfile("home", 52.52, 13.41, "")
r, _ := s.CreateRoom(p.ID, "office", 15, 3, "S", "", 0, "", "", DefaultRoomParams())
// No AC assigned yet
cap, err := s.GetRoomACCapacity(r.ID)
if err != nil {
t.Fatalf("GetRoomACCapacity: %v", err)
}
if cap != 0 {
t.Errorf("expected 0 BTU/h, got %v", cap)
}
// Assign AC
ac, _ := s.CreateACUnit(p.ID, "Portable", "portable", 8000, false, 10)
s.AssignACToRoom(ac.ID, r.ID)
cap, err = s.GetRoomACCapacity(r.ID)
if err != nil {
t.Fatalf("GetRoomACCapacity: %v", err)
}
if cap != 8000 {
t.Errorf("expected 8000 BTU/h, got %v", cap)
}
}
// --- Device CRUD ---
func TestDeviceCRUD(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProfile("home", 52.52, 13.41, "")
r, _ := s.CreateRoom(p.ID, "office", 15, 3, "S", "", 0, "", "", DefaultRoomParams())
d, err := s.CreateDevice(r.ID, "Gaming PC", "pc", 65, 200, 450, 1.0)
if err != nil {
t.Fatalf("CreateDevice: %v", err)
}
if d.Name != "Gaming PC" || d.WattsTypical != 200 {
t.Errorf("unexpected device: %+v", d)
}
devices, err := s.ListDevices(r.ID)
if err != nil {
t.Fatalf("ListDevices: %v", err)
}
if len(devices) != 1 {
t.Errorf("ListDevices len = %d, want 1", len(devices))
}
allDevices, err := s.ListAllDevices(p.ID)
if err != nil {
t.Fatalf("ListAllDevices: %v", err)
}
if len(allDevices) != 1 {
t.Errorf("ListAllDevices len = %d, want 1", len(allDevices))
}
if err := s.DeleteDevice(d.ID); err != nil {
t.Fatalf("DeleteDevice: %v", err)
}
_, err = s.GetDevice(d.ID)
if err == nil {
t.Error("expected error after delete")
}
}
// --- Occupant CRUD ---
func TestOccupantCRUD(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProfile("home", 52.52, 13.41, "")
r, _ := s.CreateRoom(p.ID, "office", 15, 3, "S", "", 0, "", "", DefaultRoomParams())
o, err := s.CreateOccupant(r.ID, 1, "sedentary", false)
if err != nil {
t.Fatalf("CreateOccupant: %v", err)
}
if o.Count != 1 || o.ActivityLevel != "sedentary" || o.Vulnerable {
t.Errorf("unexpected occupant: %+v", o)
}
oVuln, err := s.CreateOccupant(r.ID, 1, "sleeping", true)
if err != nil {
t.Fatal(err)
}
if !oVuln.Vulnerable {
t.Error("expected vulnerable=true")
}
occupants, err := s.ListOccupants(r.ID)
if err != nil {
t.Fatalf("ListOccupants: %v", err)
}
if len(occupants) != 2 {
t.Errorf("ListOccupants len = %d, want 2", len(occupants))
}
all, err := s.ListAllOccupants(p.ID)
if err != nil {
t.Fatalf("ListAllOccupants: %v", err)
}
if len(all) != 2 {
t.Errorf("ListAllOccupants len = %d, want 2", len(all))
}
if err := s.DeleteOccupant(o.ID); err != nil {
t.Fatal(err)
}
_, err = s.GetOccupant(o.ID)
if err == nil {
t.Error("expected error after delete")
}
}
// --- AC Unit CRUD ---
func TestACUnitCRUD(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProfile("home", 52.52, 13.41, "")
r, _ := s.CreateRoom(p.ID, "office", 15, 3, "S", "", 0, "", "", DefaultRoomParams())
ac, err := s.CreateACUnit(p.ID, "Portable", "portable", 8000, true, 10.5)
if err != nil {
t.Fatalf("CreateACUnit: %v", err)
}
if ac.Name != "Portable" || ac.CapacityBTU != 8000 || !ac.HasDehumidify {
t.Errorf("unexpected ac: %+v", ac)
}
if err := s.AssignACToRoom(ac.ID, r.ID); err != nil {
t.Fatalf("AssignACToRoom: %v", err)
}
rooms, err := s.GetACRoomAssignments(ac.ID)
if err != nil {
t.Fatalf("GetACRoomAssignments: %v", err)
}
if len(rooms) != 1 || rooms[0] != r.ID {
t.Errorf("unexpected room assignments: %v", rooms)
}
if err := s.UnassignACFromRoom(ac.ID, r.ID); err != nil {
t.Fatal(err)
}
rooms, _ = s.GetACRoomAssignments(ac.ID)
if len(rooms) != 0 {
t.Error("expected empty after unassign")
}
units, err := s.ListACUnits(p.ID)
if err != nil {
t.Fatal(err)
}
if len(units) != 1 {
t.Errorf("ListACUnits len = %d, want 1", len(units))
}
if err := s.DeleteACUnit(ac.ID); err != nil {
t.Fatal(err)
}
}
// --- Forecast CRUD ---
func TestForecastUpsertAndQuery(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProfile("home", 52.52, 13.41, "")
ts := time.Date(2025, 7, 15, 14, 0, 0, 0, time.UTC)
temp := 35.5
hum := 40.0
f := &Forecast{
ProfileID: p.ID,
Timestamp: ts,
TemperatureC: &temp,
HumidityPct: &hum,
Source: "openmeteo",
}
if err := s.UpsertForecast(f); err != nil {
t.Fatalf("UpsertForecast: %v", err)
}
// Upsert again (should update, not duplicate)
temp2 := 36.0
f.TemperatureC = &temp2
if err := s.UpsertForecast(f); err != nil {
t.Fatalf("UpsertForecast update: %v", err)
}
from := ts.Add(-time.Hour)
to := ts.Add(time.Hour)
forecasts, err := s.GetForecasts(p.ID, from, to, "openmeteo")
if err != nil {
t.Fatalf("GetForecasts: %v", err)
}
if len(forecasts) != 1 {
t.Fatalf("GetForecasts len = %d, want 1", len(forecasts))
}
if *forecasts[0].TemperatureC != 36.0 {
t.Errorf("temperature = %v, want 36.0", *forecasts[0].TemperatureC)
}
// Cleanup
deleted, err := s.CleanupOldForecasts(ts.Add(time.Hour * 24))
if err != nil {
t.Fatal(err)
}
if deleted != 1 {
t.Errorf("deleted = %d, want 1", deleted)
}
}
// --- Warning CRUD ---
func TestWarningUpsertAndQuery(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProfile("home", 52.52, 13.41, "")
now := time.Now().UTC()
w := &Warning{
ProfileID: p.ID,
WarningID: "dwd-heat-001",
EventType: "STARKE HITZE",
Severity: "Severe",
Headline: "Heat warning Berlin",
Description: "Temperatures up to 37C expected",
Instruction: "Stay hydrated",
Onset: now,
Expires: now.Add(48 * time.Hour),
}
if err := s.UpsertWarning(w); err != nil {
t.Fatalf("UpsertWarning: %v", err)
}
// Upsert again (should update)
w.Headline = "Updated heat warning"
if err := s.UpsertWarning(w); err != nil {
t.Fatalf("UpsertWarning update: %v", err)
}
got, err := s.GetWarning("dwd-heat-001")
if err != nil {
t.Fatalf("GetWarning: %v", err)
}
if got.Headline != "Updated heat warning" {
t.Errorf("headline = %s, want 'Updated heat warning'", got.Headline)
}
active, err := s.GetActiveWarnings(p.ID, now)
if err != nil {
t.Fatal(err)
}
if len(active) != 1 {
t.Errorf("active warnings = %d, want 1", len(active))
}
// Check expired don't show
expired, _ := s.GetActiveWarnings(p.ID, now.Add(72*time.Hour))
if len(expired) != 0 {
t.Errorf("expected 0 active warnings after expiry, got %d", len(expired))
}
// Cleanup
deleted, err := s.CleanupExpiredWarnings(now.Add(72 * time.Hour))
if err != nil {
t.Fatal(err)
}
if deleted != 1 {
t.Errorf("deleted = %d, want 1", deleted)
}
}
// --- Toggle ---
func TestToggle(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProfile("home", 52.52, 13.41, "")
if err := s.SetToggle(p.ID, "gaming", true); err != nil {
t.Fatalf("SetToggle: %v", err)
}
toggles, err := s.GetToggles(p.ID)
if err != nil {
t.Fatal(err)
}
if len(toggles) != 1 || !toggles[0].Active || toggles[0].Name != "gaming" {
t.Errorf("unexpected toggles: %+v", toggles)
}
active, err := s.GetActiveToggleNames(p.ID)
if err != nil {
t.Fatal(err)
}
if !active["gaming"] {
t.Error("expected gaming toggle active")
}
if err := s.SetToggle(p.ID, "gaming", false); err != nil {
t.Fatal(err)
}
active, _ = s.GetActiveToggleNames(p.ID)
if active["gaming"] {
t.Error("expected gaming toggle inactive")
}
}
// --- Cascade Delete ---
func TestCascadeDeleteProfile(t *testing.T) {
s := newTestStore(t)
p, _ := s.CreateProfile("home", 52.52, 13.41, "")
r, _ := s.CreateRoom(p.ID, "office", 15, 3, "S", "", 0, "", "", DefaultRoomParams())
s.CreateDevice(r.ID, "PC", "pc", 65, 200, 450, 1.0)
s.CreateOccupant(r.ID, 1, "sedentary", false)
s.CreateACUnit(p.ID, "AC", "portable", 8000, false, 10)
if err := s.DeleteProfile(p.ID); err != nil {
t.Fatal(err)
}
rooms, _ := s.ListRooms(p.ID)
if len(rooms) != 0 {
t.Error("expected rooms deleted on cascade")
}
devices, _ := s.ListDevices(r.ID)
if len(devices) != 0 {
t.Error("expected devices deleted on cascade")
}
}