package main import ( "context" "fmt" "net/http" "net/http/httptest" "net/url" "strings" "testing" "time" "github.com/kami/maven/internal/ipc" "github.com/kami/maven/internal/tasks" ) // fakeTaskCore serves the /tasks handler: a canned list plus a log of the // writes the page made. type fakeTaskCore struct { ipc.UnimplementedCoreAPI tasks []ipc.Task listErr error captured []ipc.CaptureTaskReq created bool captureErr error statusID int64 statusVal string statusBy string statusErr error promoted bool // The promote path's two extra writes. fields []any edits []editCall editErr error fieldErr error } // editCall records one EditTask, so a test can say what the form actually sent // down rather than only that the promotion succeeded. type editCall struct { ID int64 Text string Due *time.Time Weight int } func (f *fakeTaskCore) EditTask(_ context.Context, id int64, text string, due *time.Time, weight int) error { f.edits = append(f.edits, editCall{id, text, due, weight}) return f.editErr } func (f *fakeTaskCore) SetTaskFields(_ context.Context, id int64, doneWhen, blockedOn string) error { f.fields = append(f.fields, []any{id, doneWhen, blockedOn}) return f.fieldErr } func (f *fakeTaskCore) ListTasks(_ context.Context, status string) ([]ipc.Task, error) { if f.listErr != nil { return nil, f.listErr } return f.tasks, nil } func (f *fakeTaskCore) CaptureTask(_ context.Context, req ipc.CaptureTaskReq) (ipc.CaptureTaskResp, error) { f.captured = append(f.captured, req) if f.captureErr != nil { return ipc.CaptureTaskResp{}, f.captureErr } return ipc.CaptureTaskResp{ID: 7, Created: f.created, Promoted: f.promoted}, nil } func (f *fakeTaskCore) SetTaskStatus(_ context.Context, id int64, status string, _ time.Time, by string) error { f.statusID, f.statusVal, f.statusBy = id, status, by return f.statusErr } func TestHandleTasksSplitsCandidatesFromOpen(t *testing.T) { now := time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC) resolved := now.Add(time.Hour) core := &fakeTaskCore{tasks: []ipc.Task{ {ID: 1, Text: "купить молоко", Source: "tap:voice", Status: "open", CreatedTs: now}, {ID: 2, Text: "продлить страховку", Source: "email:kami", Evidence: "полис истекает", Status: "candidate", CreatedTs: now}, {ID: 3, Text: "полить цветы", Source: "tap:web", Status: "done", CreatedTs: now, Resolved: &resolved}, }} rec := httptest.NewRecorder() handleTasks(rec, httptest.NewRequest(http.MethodGet, "/tasks", nil), core) if rec.Code != http.StatusOK { t.Fatalf("status = %d", rec.Code) } body := rec.Body.String() for _, want := range []string{ "купить молоко", "продлить страховку", "полить цветы", "полис истекает", // the evidence trail is visible for review "found, not confirmed", // candidates get their own section } { if !strings.Contains(body, want) { t.Errorf("body missing %q", want) } } // The candidate must offer the intake form, and it asks for a definition of // done before it will confirm anything (V-511). if !strings.Contains(body, "value=promote") { t.Error("candidate row has no confirm action") } if !strings.Contains(body, "name=done_when") { t.Error("the confirm form does not ask for a definition of done") } } func TestHandleTasksAddCaptures(t *testing.T) { core := &fakeTaskCore{created: true} form := url.Values{"action": {"add"}, "text": {" позвонить в банк "}, "due": {"2026-08-05"}} req := httptest.NewRequest(http.MethodPost, "/tasks", strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") rec := httptest.NewRecorder() handleTasks(rec, req, core) if rec.Code != http.StatusOK { t.Fatalf("status = %d", rec.Code) } if len(core.captured) != 1 { t.Fatalf("captured %d requests, want 1", len(core.captured)) } got := core.captured[0] if got.Text != "позвонить в банк" { t.Errorf("text = %q, want trimmed", got.Text) } if got.Source != "tap:web" { t.Errorf("source = %q, want tap:web", got.Source) } if got.Status != "open" { t.Errorf("status = %q — a task he typed himself is open, not a candidate", got.Status) } if got.Due == nil || got.Due.Format("2006-01-02") != "2026-08-05" { t.Errorf("due = %v", got.Due) } if !strings.Contains(rec.Body.String(), "added task") { t.Error("no confirmation message") } } func TestHandleTasksAddSaysAlreadyOnTheList(t *testing.T) { core := &fakeTaskCore{created: false} form := url.Values{"action": {"add"}, "text": {"купить молоко"}} req := httptest.NewRequest(http.MethodPost, "/tasks", strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") rec := httptest.NewRecorder() handleTasks(rec, req, core) if !strings.Contains(rec.Body.String(), "already on the list") { t.Error("a deduped capture must not claim it saved something new") } } func TestHandleTasksStatusActions(t *testing.T) { for _, tc := range []struct{ action, want string }{ {"confirm", "open"}, {"done", "done"}, {"drop", "dropped"}, } { core := &fakeTaskCore{} form := url.Values{"action": {tc.action}, "id": {"42"}} req := httptest.NewRequest(http.MethodPost, "/tasks", strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") handleTasks(httptest.NewRecorder(), req, core) if core.statusID != 42 || core.statusVal != tc.want { t.Errorf("%s → SetTaskStatus(%d, %q), want (42, %q)", tc.action, core.statusID, core.statusVal, tc.want) } } } func TestHandleTasksRejectsBadPost(t *testing.T) { core := &fakeTaskCore{} form := url.Values{"action": {"explode"}, "id": {"1"}} req := httptest.NewRequest(http.MethodPost, "/tasks", strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") rec := httptest.NewRecorder() handleTasks(rec, req, core) // The page still renders, with the error inline — and nothing was written. if rec.Code != http.StatusOK { t.Fatalf("status = %d", rec.Code) } if core.statusVal != "" || len(core.captured) != 0 { t.Error("an unknown action must write nothing") } if !strings.Contains(rec.Body.String(), "unknown action") { t.Error("error not surfaced on the page") } } func TestHandleTasksSanitizesCoreWriteFailure(t *testing.T) { core := &fakeTaskCore{captureErr: fmt.Errorf("sqlite /private/maven.db: key material rejected")} form := url.Values{"action": {"add"}, "text": {"что-то"}} req := httptest.NewRequest(http.MethodPost, "/tasks", strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") rec := httptest.NewRecorder() handleTasks(rec, req, core) body := rec.Body.String() for _, want := range []string{"task update failed", string(problemCoreChangeFailed), "request "} { if !strings.Contains(body, want) { t.Errorf("sanitized task error missing %q: %s", want, body) } } if strings.Contains(body, "/private/maven.db") || strings.Contains(body, "key material") { t.Errorf("task page disclosed the core error: %s", body) } } func TestHandleTasksNoCore(t *testing.T) { rec := httptest.NewRecorder() handleTasks(rec, httptest.NewRequest(http.MethodGet, "/tasks", nil), nil) if rec.Code != http.StatusServiceUnavailable { t.Errorf("status = %d, want 503", rec.Code) } } // The open list is ordered by the ranker, and the reason is shown so the page // says why a task is first instead of asking him to trust the order. func TestHandleTasksOrdersOpenByRank(t *testing.T) { now := time.Now() due := now core := &fakeTaskCore{tasks: []ipc.Task{ {ID: 1, Text: "купить молоко", Status: "open", CreatedTs: now}, {ID: 2, Text: "оплатить интернет", Status: "open", CreatedTs: now, Due: &due}, }} rec := httptest.NewRecorder() handleTasks(rec, httptest.NewRequest(http.MethodGet, "/tasks", nil), core) body := rec.Body.String() if strings.Index(body, "оплатить интернет") > strings.Index(body, "купить молоко") { t.Error("want the dated task rendered first") } if !strings.Contains(body, "сегодня") { t.Error("want the ranker's reason shown in the why column") } } // A candidate is ranked into place but never carries a priority reason: its due // date is Maven's reading of a mail, not something he stated. func TestHandleTasksHidesCandidateReason(t *testing.T) { now := time.Now() due := now core := &fakeTaskCore{tasks: []ipc.Task{ {ID: 1, Text: "продлить страховку", Status: "candidate", CreatedTs: now, Due: &due}, }} rec := httptest.NewRecorder() handleTasks(rec, httptest.NewRequest(http.MethodGet, "/tasks", nil), core) if strings.Contains(rec.Body.String(), "сегодня") { t.Error("a candidate must not be shown with a priority reason") } } func TestApplyTaskPostCarriesWeight(t *testing.T) { core := &fakeTaskCore{created: true} form := url.Values{"action": {"add"}, "text": {"оплатить интернет"}, "weight": {"3"}} req := httptest.NewRequest(http.MethodPost, "/tasks", strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") handleTasks(httptest.NewRecorder(), req, core) if len(core.captured) != 1 || core.captured[0].Weight != 3 { t.Fatalf("captured = %+v, want weight 3", core.captured) } } // Confirming a candidate posts the importance select whether or not a date is // set. The weight write hung off `if due != nil`, so "срочно" with no deadline // was read off the form and thrown away, and the row came back normal. func TestPromoteCandidateCarriesWeightWithoutADueDate(t *testing.T) { core := &fakeTaskCore{} form := url.Values{ "action": {"promote"}, "id": {"4"}, "text": {"продлить страховку"}, "done_when": {"полис на руках"}, "weight": {"3"}, } req := httptest.NewRequest(http.MethodPost, "/tasks", strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") handleTasks(httptest.NewRecorder(), req, core) if len(core.edits) != 1 { t.Fatalf("edits = %+v, want the weight written once", core.edits) } if core.edits[0].Weight != 3 || core.edits[0].ID != 4 { t.Errorf("edit = %+v, want id 4 at weight 3", core.edits[0]) } if core.edits[0].Due != nil { t.Errorf("edit invented a due date: %v", core.edits[0].Due) } if core.statusVal != "open" { t.Errorf("status = %q, want the candidate promoted", core.statusVal) } } // A promote with neither field set still writes nothing: the row is unchanged // apart from its status, and an EditTask here would be a no-op that can fail. func TestPromoteCandidateWithNoDateAndNoWeightDoesNotEdit(t *testing.T) { core := &fakeTaskCore{} form := url.Values{ "action": {"promote"}, "id": {"4"}, "text": {"продлить страховку"}, "done_when": {"полис на руках"}, "weight": {"0"}, } req := httptest.NewRequest(http.MethodPost, "/tasks", strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") handleTasks(httptest.NewRecorder(), req, core) if len(core.edits) != 0 { t.Errorf("edits = %+v, want none", core.edits) } } // Out of range clamps rather than 400s; a non-number is a real client error. func TestApplyTaskPostClampsWeight(t *testing.T) { core := &fakeTaskCore{created: true} form := url.Values{"action": {"add"}, "text": {"что-то"}, "weight": {"99"}} req := httptest.NewRequest(http.MethodPost, "/tasks", strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") handleTasks(httptest.NewRecorder(), req, core) if core.captured[0].Weight != tasks.MaxWeight { t.Errorf("weight = %d, want the cap", core.captured[0].Weight) } } // The page ranked with the wall clock while the daemon path ranked with a clock // it was handed, so this was the one surface that could only be tested at // whatever time it happened to run. func TestHandleTasksRanksAtTheInjectedClock(t *testing.T) { fixed := time.Date(2026, 8, 1, 10, 0, 0, 0, time.FixedZone("UTC+4", 4*3600)) old := now now = func() time.Time { return fixed } t.Cleanup(func() { now = old }) // Due tomorrow, local time, stored the way the store hands it back: UTC. due := time.Date(2026, 8, 2, 0, 0, 0, 0, fixed.Location()).UTC() core := &fakeTaskCore{tasks: []ipc.Task{ {ID: 1, Text: "оплатить интернет", Status: "open", CreatedTs: fixed, Due: &due}, }} rec := httptest.NewRecorder() handleTasks(rec, httptest.NewRequest(http.MethodGet, "/tasks", nil), core) body := rec.Body.String() if !strings.Contains(body, "завтра") { t.Errorf("why column does not say завтра: %q", why(body)) } if strings.Contains(body, "сегодня") || strings.Contains(body, "просрочено") { t.Error("a task due tomorrow was ranked as today's or overdue") } } // why is a crude excerpt of the rendered why column, for a readable failure. func why(body string) string { i := strings.Index(body, "