35c6ff5a71
Persist reminder presentations and retry state, atomically complete collapsed deliveries, fall back across away reaches, and block permanent failures visibly (V-715, V-678). Fail closed when enabled integrations lack credentials and keep remote arms explicitly dark (V-691). Give mavweb one sanitized, request-correlated error contract (V-689). Owner explicitly requested direct commits to master.
432 lines
16 KiB
Go
432 lines
16 KiB
Go
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, "<td class=hint>")
|
|
if i < 0 {
|
|
return body
|
|
}
|
|
j := i + 200
|
|
if j > len(body) {
|
|
j = len(body)
|
|
}
|
|
return body[i:j]
|
|
}
|
|
|
|
// The resolved section rendered every row that ever existed.
|
|
func TestHandleTasksBoundsResolved(t *testing.T) {
|
|
base := time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC)
|
|
var rows []ipc.Task
|
|
for i := 0; i < resolvedShown+10; i++ {
|
|
ts := base.Add(time.Duration(i) * time.Minute)
|
|
rows = append(rows, ipc.Task{
|
|
ID: int64(i + 1), Text: fmt.Sprintf("задача %d", i), Status: "done",
|
|
CreatedTs: ts, Resolved: &ts,
|
|
})
|
|
}
|
|
core := &fakeTaskCore{tasks: rows}
|
|
rec := httptest.NewRecorder()
|
|
handleTasks(rec, httptest.NewRequest(http.MethodGet, "/tasks", nil), core)
|
|
body := rec.Body.String()
|
|
if n := strings.Count(body, "задача "); n != resolvedShown {
|
|
t.Errorf("rendered %d resolved rows, want the %d-row bound", n, resolvedShown)
|
|
}
|
|
if !strings.Contains(body, "most recent are shown") {
|
|
t.Error("the page must say it is showing only part of the history")
|
|
}
|
|
}
|
|
|
|
// A capture over a candidate is a confirmation, not a duplicate.
|
|
func TestHandleTasksAddSaysPromoted(t *testing.T) {
|
|
core := &fakeTaskCore{promoted: true}
|
|
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(), "confirmed a candidate") {
|
|
t.Error("a promoted capture must not read as a duplicate")
|
|
}
|
|
}
|
|
|
|
// Sscanf accepted "3junk" as 3, and the same call parsed the row id.
|
|
func TestApplyTaskPostRejectsTrailingGarbage(t *testing.T) {
|
|
core := &fakeTaskCore{created: true}
|
|
form := url.Values{"action": {"add"}, "text": {"что-то"}, "weight": {"3junk"}}
|
|
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 len(core.captured) != 0 {
|
|
t.Errorf("captured %+v, want nothing on a malformed weight", core.captured)
|
|
}
|
|
if !strings.Contains(rec.Body.String(), "bad weight") {
|
|
t.Error("error not surfaced on the page")
|
|
}
|
|
|
|
core = &fakeTaskCore{}
|
|
form = url.Values{"action": {"done"}, "id": {"42junk"}}
|
|
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 != 0 {
|
|
t.Errorf("SetTaskStatus called with id %d on a malformed id", core.statusID)
|
|
}
|
|
}
|
|
|
|
// A resolution says what resolved it: resolved_ts recorded when and never by
|
|
// what.
|
|
func TestHandleTasksRecordsTheCaller(t *testing.T) {
|
|
core := &fakeTaskCore{}
|
|
form := url.Values{"action": {"done"}, "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.statusBy != "tap:web" {
|
|
t.Errorf("resolved by %q, want tap:web", core.statusBy)
|
|
}
|
|
}
|