package api import ( "strings" "testing" "time" "drivervault/apiserver/internal/models" ) // 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 step 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 step(action, at string, amps float64) models.ChargingStep { return models.ChargingStep{Action: action, Time: at, Amps: amps} } func TestDueSteps(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) // A whole night in one task, which is the point of a flow: it opens, it // eases off, it closes. base := chargingTaskRecord{ Zone: "Europe/Warsaw", Enabled: true, Steps: []models.ChargingStep{ step("start", "23:00", 0), step("limit", "01:00", 10), step("stop", "06:30", 0), }, } due := dueSteps(base, at2300) if len(due) != 1 || due[0].Action != "start" { t.Fatalf("at 23:00 got %+v, want just the start step", due) } // The same instant handed over as UTC. The zone on the task is what the // times are read in, so where the server thinks it is must not matter. if got := dueSteps(base, at2300.UTC()); len(got) != 1 || got[0].Action != "start" { t.Errorf("the step stopped being due when the same instant arrived as UTC: %+v", got) } // The other two steps, each in its own minute and no other. if got := dueSteps(base, time.Date(2026, 9, 3, 1, 0, 5, 0, warsaw)); len(got) != 1 || got[0].Action != "limit" { t.Errorf("at 01:00 got %+v, want the limit step", got) } if got := dueSteps(base, time.Date(2026, 9, 3, 6, 30, 5, 0, warsaw)); len(got) != 1 || got[0].Action != "stop" { t.Errorf("at 06:30 got %+v, want the stop step", got) } // A minute the flow says nothing about fires nothing — the point being that // a task with a step at 23:00 is not "on" from 23:00 onwards. for _, at := range []time.Time{at2300.Add(time.Minute), at2300.Add(-time.Minute), time.Date(2026, 9, 3, 3, 0, 0, 0, warsaw)} { if got := dueSteps(base, at); len(got) != 0 { t.Errorf("at %s got %+v, want nothing due", at.Format("15:04"), got) } } // Weekdays gate the whole task. 2026-09-02 is a Wednesday (3). weeknights := base weeknights.Days = []int{1, 2, 3, 4, 5} if len(dueSteps(weeknights, at2300)) != 1 { t.Error("a weeknight task did not fire on a Wednesday") } weekends := base weekends.Days = []int{0, 6} if len(dueSteps(weekends, at2300)) != 0 { 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 len(dueSteps(fired, at2300.Add(20*time.Second))) != 0 { t.Error("a step 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 len(dueSteps(yesterday, at2300)) != 1 { t.Error("yesterday's run stopped today's from firing") } // A step whose time is not a time of day fires nothing rather than firing at // midnight, which is what a zero hour and minute would have meant — and it // does not take the rest of the flow down with it. broken := base broken.Steps = []models.ChargingStep{step("start", "later", 0), step("stop", "23:00", 0)} got := dueSteps(broken, at2300) if len(got) != 1 || got[0].Action != "stop" { t.Errorf("got %+v, want only the readable step", got) } // Two steps timed to the same minute contradict each other, but nothing // stops somebody writing them, so both are returned rather than one // silently winning. clash := base clash.Steps = []models.ChargingStep{step("start", "23:00", 0), step("boost", "23:00", 0)} if got := dueSteps(clash, at2300); len(got) != 2 { t.Errorf("got %+v, want both steps sharing the minute", got) } } // 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 TestDueStepsUnknownZone(t *testing.T) { now := time.Now() rec := chargingTaskRecord{ Zone: "Mars/Olympus_Mons", Steps: []models.ChargingStep{step("start", now.Format("15:04"), 0)}, } if len(dueSteps(rec, now)) != 1 { 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 TestNormalizeSteps(t *testing.T) { // A night, written the way it is meant: it opens, it eases off, it closes. // The half-written times are the client's business to send and this // function's business to pad. got, err := normalizeSteps([]models.ChargingStep{ step("start", "23:00", 0), step("limit", "1:00", 10), step("stop", "6:30", 0), }) if err != nil { t.Fatalf("normalizeSteps: %v", err) } // Kept in the order it was written. Sorting by the clock would file the // 23:00 start last, behind the stop that closes it, which is not the flow // anybody described — and firing does not depend on the order at all. wantTimes := []string{"23:00", "01:00", "06:30"} for i, want := range wantTimes { if got[i].Time != want { t.Errorf("step %d time = %q, want %q (whole flow: %+v)", i, got[i].Time, want, got) } } for name, steps := range map[string][]models.ChargingStep{ "no steps at all": {}, "an unknown action": {step("melt", "23:00", 0)}, "a time that is not one": {step("start", "half past", 0)}, "a limit with no amps": {step("limit", "23:00", 0)}, "amps below the floor": {step("limit", "23:00", 3)}, "amps above the rating": {step("limit", "23:00", 40)}, } { if _, err := normalizeSteps(steps); err == nil { t.Errorf("normalizeSteps accepted %s", name) } } // The cap bounds what the sweep re-reads every thirty seconds. tooMany := make([]models.ChargingStep, maxTaskSteps+1) for i := range tooMany { tooMany[i] = step("start", "23:00", 0) } if _, err := normalizeSteps(tooMany); err == nil { t.Errorf("normalizeSteps accepted %d steps, past the cap of %d", len(tooMany), maxTaskSteps) } } func TestTaskPayloadValidation(t *testing.T) { str := func(s string) *string { return &s } steps := func(s ...models.ChargingStep) *[]models.ChargingStep { return &s } full := chargingTaskBody{ Name: str(" Night rate "), Steps: steps(step("start", "23:00", 0), step("stop", "6:30", 0)), } 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["enabled"] != true { t.Error("a new task was written switched off; nobody fills in a schedule to leave it off") } if flow, _ := payload["steps"].([]models.ChargingStep); len(flow) != 2 || flow[0].Time != "23:00" { t.Errorf("steps = %+v, want both, as written", payload["steps"]) } // The two things a task cannot exist without. for _, missing := range []chargingTaskBody{ {Steps: steps(step("start", "23:00", 0))}, {Name: str("x")}, {Name: str(" "), Steps: steps(step("start", "23:00", 0))}, } { if _, err := taskPayload(missing, true); err == nil { t.Errorf("taskPayload accepted an incomplete task: %+v", missing) } } // A partial write says only what it names, so a task switched off from the // list keeps its flow, its chargers and its days. 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) } } // The list is ordered by the time a task begins, which lives inside its flow // rather than in a column PocketBase could sort on. func TestFirstStepTime(t *testing.T) { rec := chargingTaskRecord{Steps: []models.ChargingStep{step("start", "23:00", 0), step("stop", "06:30", 0)}} if got := rec.firstStepTime(); got != "23:00" { t.Errorf("firstStepTime = %q, want the first step's 23:00", got) } // A task with no steps cannot be written, but sorting must not depend on // that — it sorts last rather than first. if got := (chargingTaskRecord{}).firstStepTime(); got < "23:59" { t.Errorf("an empty task sorted to %q, want it last", got) } } 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") } }