package api import ( "strings" "testing" "time" ) // The scheduler's clock is the part worth pinning down: it decides on its own, // with nobody watching, whether to send a car a command. A task that fires in // the wrong hour is worse than one that does not fire at all, so the zone, the // weekday and the not-twice-in-one-minute guard are each checked here. func TestTaskIsDue(t *testing.T) { warsaw, err := time.LoadLocation("Europe/Warsaw") if err != nil { t.Fatalf("Europe/Warsaw: %v", err) } // A Wednesday, 23:00 in Warsaw — 22:00 UTC. at2300 := time.Date(2026, 9, 2, 23, 0, 30, 0, warsaw) base := chargingTaskRecord{Time: "23:00", Zone: "Europe/Warsaw", Enabled: true} if !taskIsDue(base, at2300) { t.Error("a task set for 23:00 is not due at 23:00 in its own zone") } // The same instant, handed over as UTC. The zone on the task is what the // time is read in, so where the server thinks it is must not matter. if !taskIsDue(base, at2300.UTC()) { t.Error("the task stopped being due when the same instant arrived as UTC") } if taskIsDue(base, at2300.Add(time.Minute)) { t.Error("a task fired a minute after its time") } if taskIsDue(base, at2300.Add(-time.Minute)) { t.Error("a task fired a minute before its time") } // Weekdays. 2026-09-02 is a Wednesday (3). weeknights := base weeknights.Days = []int{1, 2, 3, 4, 5} if !taskIsDue(weeknights, at2300) { t.Error("a weeknight task is not due on a Wednesday") } weekends := base weekends.Days = []int{0, 6} if taskIsDue(weekends, at2300) { t.Error("a weekend task fired on a Wednesday") } // Fired already inside this minute: the sweep runs more than once a minute, // and the second sweep must not send the command again. fired := base fired.LastRun = at2300.UTC().Format(time.RFC3339) if taskIsDue(fired, at2300.Add(20*time.Second)) { t.Error("a task fired twice inside its own minute") } // Yesterday's firing is not this minute's. yesterday := base yesterday.LastRun = at2300.Add(-24 * time.Hour).UTC().Format(time.RFC3339) if !taskIsDue(yesterday, at2300) { t.Error("yesterday's run stopped today's from firing") } // A time that is not a time of day fires nothing rather than firing at // midnight, which is what a zero hour and minute would have meant. broken := base broken.Time = "later" if taskIsDue(broken, at2300) { t.Error("a task with an unreadable time fired") } } // A zone the host has no database for falls back to the server's own clock // rather than silently shifting the schedule to UTC. func TestTaskIsDueUnknownZone(t *testing.T) { now := time.Now() rec := chargingTaskRecord{ Time: now.Format("15:04"), Zone: "Mars/Olympus_Mons", } if !taskIsDue(rec, now) { t.Error("a task in an unknown zone did not fall back to the server's clock") } } func TestParseHHMM(t *testing.T) { for _, tc := range []struct { in string h, m int wantOK bool }{ {in: "23:00", h: 23, m: 0, wantOK: true}, {in: "7:05", h: 7, m: 5, wantOK: true}, {in: " 00:00 ", h: 0, m: 0, wantOK: true}, {in: "24:00"}, {in: "12:60"}, {in: "12:5"}, // a half-typed minute is not a minute {in: "123:00"}, // nor is a three-digit hour {in: "12"}, {in: ""}, {in: "ab:cd"}, } { h, m, ok := parseHHMM(tc.in) if ok != tc.wantOK { t.Errorf("parseHHMM(%q) ok = %v, want %v", tc.in, ok, tc.wantOK) continue } if ok && (h != tc.h || m != tc.m) { t.Errorf("parseHHMM(%q) = %d:%d, want %d:%d", tc.in, h, m, tc.h, tc.m) } } } func TestNormalizeDays(t *testing.T) { got := normalizeDays([]int{5, 1, 5, 9, -1, 0}) want := []int{0, 1, 5} if len(got) != len(want) { t.Fatalf("normalizeDays = %v, want %v", got, want) } for i := range want { if got[i] != want[i] { t.Fatalf("normalizeDays = %v, want %v", got, want) } } // Empty means every day and stays empty rather than becoming all seven — // the two read the same today, but only one keeps meaning "every day". if len(normalizeDays(nil)) != 0 { t.Error("normalizeDays(nil) invented days") } } func TestTaskPayloadValidation(t *testing.T) { str := func(s string) *string { return &s } num := func(f float64) *float64 { return &f } // A whole task, as a create sends it. full := chargingTaskBody{ Name: str(" Night rate "), Action: str("limit"), Time: str("7:05"), Amps: num(10), } payload, err := taskPayload(full, true) if err != nil { t.Fatalf("taskPayload: %v", err) } if payload["name"] != "Night rate" { t.Errorf("name = %v, want it trimmed", payload["name"]) } if payload["time"] != "07:05" { t.Errorf("time = %v, want the padded 07:05", payload["time"]) } if payload["enabled"] != true { t.Error("a new task was written switched off; nobody fills in a schedule to leave it off") } // The fields a task cannot exist without. for _, missing := range []chargingTaskBody{ {Action: str("start"), Time: str("23:00")}, {Name: str("x"), Time: str("23:00")}, {Name: str("x"), Action: str("start")}, {Name: str(" "), Action: str("start"), Time: str("23:00")}, {Name: str("x"), Action: str("melt"), Time: str("23:00")}, {Name: str("x"), Action: str("start"), Time: str("half past")}, } { if _, err := taskPayload(missing, true); err == nil { t.Errorf("taskPayload accepted an incomplete task: %+v", missing) } } // The charger's own floor and ceiling. for _, amps := range []float64{1, 5, 33} { if _, err := taskPayload(chargingTaskBody{Amps: num(amps)}, false); err == nil { t.Errorf("taskPayload accepted %g A, outside what the charger will take", amps) } } // A partial write says only what it names, so a task switched off from the // list keeps its days and its time. on := true partial, err := taskPayload(chargingTaskBody{Enabled: &on}, false) if err != nil { t.Fatalf("taskPayload(partial): %v", err) } if len(partial) != 1 { t.Errorf("a switch-only write touched %v, want only enabled", partial) } } // A "limit" with no ceiling would fire and ask the charger for 0 A. Both halves // are read from whatever the write leaves behind, so a PATCH naming one of them // is checked against the stored other. func TestCheckLimitAmps(t *testing.T) { stored := chargingTaskRecord{Action: "start", Amps: 0} if err := checkLimitAmps(map[string]any{"action": "limit"}, stored); err == nil { t.Error("a task switched to limit with no amps was accepted") } if err := checkLimitAmps(map[string]any{"action": "limit", "amps": 16.0}, stored); err != nil { t.Errorf("a complete limit task was refused: %v", err) } // The amps are already on the record; the PATCH only changes the action. hasAmps := chargingTaskRecord{Action: "start", Amps: 16} if err := checkLimitAmps(map[string]any{"action": "limit"}, hasAmps); err != nil { t.Errorf("a limit task with stored amps was refused: %v", err) } // And the other way round: clearing the amps on a task that limits. limits := chargingTaskRecord{Action: "limit", Amps: 16} if err := checkLimitAmps(map[string]any{"amps": 0.0}, limits); err == nil { t.Error("the amps were cleared off a limit task") } } func TestSummarizeTaskRun(t *testing.T) { all := summarizeTaskRun([]taskRunResult{ {Name: "Home-DK", Status: "Accepted"}, {Name: "Home-PL", Status: "Accepted"}, }) if all != "2 of 2 sent" { t.Errorf("summary = %q, want \"2 of 2 sent\"", all) } // One charger failing is the case the list has to be able to show: the // summary names which, because "1 of 2 sent" alone is not actionable. some := summarizeTaskRun([]taskRunResult{ {Name: "Home-DK", Status: "Accepted"}, {Name: "Home-PL", Error: "charger is not connected"}, }) if !strings.Contains(some, "Home-PL") || !strings.Contains(some, "not connected") { t.Errorf("summary = %q, want it to name the charger that failed and why", some) } // A single failure is its own reason — no count to read past. only := summarizeTaskRun([]taskRunResult{{Name: "Home-PL", Error: "control mode is off"}}) if only != "Home-PL: control mode is off" { t.Errorf("summary = %q, want the bare reason", only) } } // The runner reads a control response the same way whichever transport answered // it: a status when the charger took the command, the endpoint's own words when // it did not. func TestCaptureWriterControlOutcome(t *testing.T) { ok := &captureWriter{header: nil} ok.WriteHeader(200) _, _ = ok.Write([]byte(`{"status":"Accepted"}`)) if got, err := ok.controlOutcome(); err != nil || got != "Accepted" { t.Errorf("controlOutcome = %q, %v; want Accepted", got, err) } // A 2xx with nothing to read still means the command went. bare := &captureWriter{} bare.WriteHeader(204) if got, err := bare.controlOutcome(); err != nil || got != "sent" { t.Errorf("controlOutcome = %q, %v; want sent", got, err) } bad := &captureWriter{} bad.WriteHeader(409) _, _ = bad.Write([]byte(`{"error":"charger is not connected to the control backend"}`)) _, err := bad.controlOutcome() if err == nil || !strings.Contains(err.Error(), "not connected") { t.Errorf("controlOutcome err = %v, want the endpoint's own reason", err) } // An error status with no envelope to read still has to say something. mute := &captureWriter{} mute.WriteHeader(502) if _, err := mute.controlOutcome(); err == nil { t.Error("a 502 with an empty body was read as a success") } }