Files
Maven/internal/store/tasks_test.go
claude a6b17ada8b a live task can be edited, a resolved one cannot (V-509)
SetTaskStatus was the only mutation on a task row, so a typo in a dictated
task was permanent and a deadline could not move. EditTask rewrites the three
fields capture set — text, due date and weight — and nothing else. Status
stays the one-way ladder SetTaskStatus owns.

Two things the task asked to settle.

A text edit re-normalises the dedupe key and can collide with another live
row. That is ErrTaskDuplicate, a refusal rather than a merge: two live rows
carry two provenances, two capture times and possibly two external
identities, and merging picks a winner for all three with nobody asked. The
surface names the row that holds the text.

A resolved task is refused outright (ErrTaskResolved). Its text is the record
of what was finished, and rewriting it rewrites history.

due nil clears the date, because clearing has to be sayable — an absent date
and "remove the date" cannot be one argument.
2026-08-05 20:39:11 +04:00

545 lines
18 KiB
Go

package store
import (
"context"
"errors"
"fmt"
"testing"
"time"
)
func TestCaptureTaskDedupesLiveWork(t *testing.T) {
ctx := context.Background()
st := newTestStore(t)
now := time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC)
first, err := st.CaptureTask(ctx, Task{Text: "купить молоко", Source: "tap:voice", CreatedTs: now})
if err != nil {
t.Fatal(err)
}
if !first.Created {
t.Fatal("first capture must create a row")
}
// Same work, different casing and punctuation — one task, not two.
again, err := st.CaptureTask(ctx, Task{Text: "Купить молоко!", Source: "tap:web", CreatedTs: now})
if err != nil {
t.Fatal(err)
}
if again.Created {
t.Error("second capture of the same live work must not create a row")
}
if again.ID != first.ID {
t.Errorf("dedupe returned id %d, want the existing %d", again.ID, first.ID)
}
live, err := st.ListTasks(ctx, "live")
if err != nil {
t.Fatal(err)
}
if len(live) != 1 {
t.Fatalf("live tasks = %d, want 1", len(live))
}
}
func TestCaptureTaskAfterDoneIsANewTask(t *testing.T) {
ctx := context.Background()
st := newTestStore(t)
now := time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC)
first, err := st.CaptureTask(ctx, Task{Text: "полить цветы", Source: "tap:voice", CreatedTs: now})
if err != nil {
t.Fatal(err)
}
id := first.ID
if err := st.SetTaskStatus(ctx, id, TaskDone, now.Add(time.Hour), "tap:web"); err != nil {
t.Fatal(err)
}
// The dedupe key is free again: a recurring errand must be capturable.
second, err := st.CaptureTask(ctx, Task{Text: "полить цветы", Source: "tap:voice", CreatedTs: now.AddDate(0, 0, 7)})
if err != nil {
t.Fatal(err)
}
id2 := second.ID
if !second.Created || id2 == id {
t.Fatalf("re-capture after done: created=%v id=%d (previous %d)", second.Created, id2, id)
}
live, err := st.ListTasks(ctx, "live")
if err != nil {
t.Fatal(err)
}
if len(live) != 1 || live[0].ID != id2 {
t.Fatalf("live = %+v, want only the new task %d", live, id2)
}
}
func TestCaptureTaskCandidateKeepsEvidence(t *testing.T) {
ctx := context.Background()
st := newTestStore(t)
now := time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC)
due := now.Add(48 * time.Hour)
res, err := st.CaptureTask(ctx, Task{
Text: "продлить страховку",
Source: "email:kami",
Evidence: "Re: страховой полис истекает",
Status: TaskCandidate,
Due: &due,
Weight: 2,
CreatedTs: now,
})
if err != nil {
t.Fatal(err)
}
got, err := st.LookupTask(ctx, res.ID)
if err != nil {
t.Fatal(err)
}
if got.Status != TaskCandidate {
t.Errorf("status = %q, want candidate", got.Status)
}
if got.Evidence != "Re: страховой полис истекает" {
t.Errorf("evidence = %q", got.Evidence)
}
if got.Due == nil || !got.Due.Equal(due.UTC()) {
t.Errorf("due = %v, want %v", got.Due, due.UTC())
}
if got.Weight != 2 {
t.Errorf("weight = %d, want 2", got.Weight)
}
if got.ResolvedTs != nil {
t.Errorf("resolved_ts = %v on a live task, want nil", got.ResolvedTs)
}
}
func TestSetTaskStatusMovesOnceForwardOnly(t *testing.T) {
ctx := context.Background()
st := newTestStore(t)
now := time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC)
res, err := st.CaptureTask(ctx, Task{Text: "записаться к врачу", Source: "email:kami", Status: TaskCandidate, CreatedTs: now})
if err != nil {
t.Fatal(err)
}
cand := res.ID
// candidate → done is not a legal move: he has to confirm it first.
if err := st.SetTaskStatus(ctx, cand, TaskDone, now, "tap:web"); !errors.Is(err, ErrTaskNotFound) {
t.Errorf("candidate→done err = %v, want ErrTaskNotFound", err)
}
// Promotion needs a definition of done; see
// TestPromotingACandidateNeedsADefinitionOfDone for that refusal.
if err := st.SetTaskFields(ctx, cand, "запись есть", ""); err != nil {
t.Fatal(err)
}
if err := st.SetTaskStatus(ctx, cand, TaskOpen, now, "tap:web"); err != nil {
t.Fatal(err)
}
if err := st.SetTaskStatus(ctx, cand, TaskDone, now.Add(time.Hour), "tap:web"); err != nil {
t.Fatal(err)
}
// Already resolved — a second resolve must not move it again.
if err := st.SetTaskStatus(ctx, cand, TaskDropped, now.Add(2*time.Hour), "tap:web"); !errors.Is(err, ErrTaskNotFound) {
t.Errorf("second resolve err = %v, want ErrTaskNotFound", err)
}
got, err := st.LookupTask(ctx, cand)
if err != nil {
t.Fatal(err)
}
if got.Status != TaskDone {
t.Errorf("status = %q, want done", got.Status)
}
if got.ResolvedTs == nil || !got.ResolvedTs.Equal(now.Add(time.Hour).UTC()) {
t.Errorf("resolved_ts = %v, want %v", got.ResolvedTs, now.Add(time.Hour).UTC())
}
}
func TestSetTaskStatusRejectsUnknownStatus(t *testing.T) {
ctx := context.Background()
st := newTestStore(t)
res, err := st.CaptureTask(ctx, Task{Text: "что-то", Source: "tap:web"})
if err != nil {
t.Fatal(err)
}
id := res.ID
if err := st.SetTaskStatus(ctx, id, "candidate", time.Now(), "tap:web"); !errors.Is(err, ErrTaskStatus) {
t.Errorf("→candidate err = %v, want ErrTaskStatus", err)
}
if err := st.SetTaskStatus(ctx, id, "urgent", time.Now(), "tap:web"); !errors.Is(err, ErrTaskStatus) {
t.Errorf("→urgent err = %v, want ErrTaskStatus", err)
}
}
func TestCaptureTaskRejectsEmptyText(t *testing.T) {
ctx := context.Background()
st := newTestStore(t)
if _, err := st.CaptureTask(ctx, Task{Text: " ", Source: "tap:voice"}); !errors.Is(err, ErrTaskEmpty) {
t.Errorf("err = %v, want ErrTaskEmpty", err)
}
}
func TestListTasksFiltersByStatus(t *testing.T) {
ctx := context.Background()
st := newTestStore(t)
now := time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC)
open1, _ := st.CaptureTask(ctx, Task{Text: "первая", Source: "tap:voice", CreatedTs: now})
_, _ = st.CaptureTask(ctx, Task{Text: "вторая", Source: "email:kami", Status: TaskCandidate, CreatedTs: now.Add(time.Minute)})
done, _ := st.CaptureTask(ctx, Task{Text: "третья", Source: "tap:voice", CreatedTs: now.Add(2 * time.Minute)})
if err := st.SetTaskStatus(ctx, done.ID, TaskDone, now.Add(time.Hour), "tap:web"); err != nil {
t.Fatal(err)
}
cands, err := st.ListTasks(ctx, TaskCandidate)
if err != nil {
t.Fatal(err)
}
if len(cands) != 1 || cands[0].Text != "вторая" {
t.Fatalf("candidates = %+v", cands)
}
opens, err := st.ListTasks(ctx, TaskOpen)
if err != nil {
t.Fatal(err)
}
if len(opens) != 1 || opens[0].ID != open1.ID {
t.Fatalf("open = %+v", opens)
}
all, err := st.ListTasks(ctx, "")
if err != nil {
t.Fatal(err)
}
if len(all) != 3 {
t.Fatalf("all = %d, want 3", len(all))
}
// Newest first.
if all[0].Text != "третья" {
t.Errorf("first = %q, want newest ('третья')", all[0].Text)
}
}
func TestNormalizeTaskText(t *testing.T) {
cases := []struct{ in, want string }{
{"Купить молоко!", "купить молоко"},
{" купить МОЛОКО ", "купить молоко"},
{"позвонить в банк (важно)", "позвонить в банк важно"},
{"", ""},
}
for _, c := range cases {
if got := NormalizeTaskText(c.in); got != c.want {
t.Errorf("NormalizeTaskText(%q) = %q, want %q", c.in, got, c.want)
}
}
}
// A mail the reader keeps seeing must not resurrect work he already finished.
// The norm key alone frees on resolve, which is right for voice and wrong for a
// mailbox: mavmaild never marks anything read, so the same message is extracted
// again on every poll, forever.
func TestCaptureTaskExternalIDSurvivesResolution(t *testing.T) {
ctx := context.Background()
st := newTestStore(t)
now := time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC)
mail := Task{
Text: "продлить страховку", Source: "email:kami", Status: TaskCandidate,
ExternalID: "email:kami#412:продлить страховку", CreatedTs: now,
}
first, err := st.CaptureTask(ctx, mail)
if err != nil {
t.Fatal(err)
}
if !first.Created {
t.Fatal("first capture must create a row")
}
// He confirms it and does it. Confirming needs a criterion (Vikunja #510).
if err := st.SetTaskFields(ctx, first.ID, "страховка продлена", ""); err != nil {
t.Fatal(err)
}
if err := st.SetTaskStatus(ctx, first.ID, TaskOpen, now.Add(time.Hour), "tap:web"); err != nil {
t.Fatal(err)
}
if err := st.SetTaskStatus(ctx, first.ID, TaskDone, now.Add(2*time.Hour), "tap:web"); err != nil {
t.Fatal(err)
}
// The next poll reads the same message again.
mail.CreatedTs = now.AddDate(0, 0, 1)
again, err := st.CaptureTask(ctx, mail)
if err != nil {
t.Fatal(err)
}
if again.Created {
t.Error("re-reading the same mail created a second task after the first was done")
}
if again.ID != first.ID {
t.Errorf("id = %d, want the resolved row %d", again.ID, first.ID)
}
live, err := st.ListTasks(ctx, "live")
if err != nil {
t.Fatal(err)
}
if len(live) != 0 {
t.Fatalf("live = %+v, want nothing: he already did this", live)
}
}
// Stating out loud a task Maven only proposed is a confirmation. Leaving it a
// candidate had her read it straight back as something he had not confirmed.
func TestCaptureTaskPromotesCandidate(t *testing.T) {
ctx := context.Background()
st := newTestStore(t)
now := time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC)
cand, err := st.CaptureTask(ctx, Task{
Text: "продлить страховку", Source: "email:kami", Status: TaskCandidate,
ExternalID: "email:kami#7:продлить страховку", CreatedTs: now,
})
if err != nil {
t.Fatal(err)
}
spoken, err := st.CaptureTask(ctx, Task{
Text: "продлить страховку", Source: "tap:voice", Status: TaskOpen,
CreatedTs: now.Add(time.Minute),
})
if err != nil {
t.Fatal(err)
}
if spoken.Created {
t.Error("capture over a live candidate must not create a second row")
}
if !spoken.Promoted {
t.Error("capture with status open over a candidate must promote it")
}
got, err := st.LookupTask(ctx, cand.ID)
if err != nil {
t.Fatal(err)
}
if got.Status != TaskOpen {
t.Errorf("status = %q, want open", got.Status)
}
if got.ResolvedTs != nil {
t.Errorf("resolved_ts = %v, want nil: the task is still live", got.ResolvedTs)
}
}
// A derived source may only ever file a candidate. The intake seam documented
// this and nothing enforced it, so a caller could skip review entirely.
func TestCaptureTaskRefusesOpenFromDerivedSource(t *testing.T) {
ctx := context.Background()
st := newTestStore(t)
_, err := st.CaptureTask(ctx, Task{Text: "оплатить счёт", Source: "email:kami", Status: TaskOpen})
if !errors.Is(err, ErrTaskStatus) {
t.Errorf("err = %v, want ErrTaskStatus", err)
}
}
// resolved_ts said when a task was resolved and never by what.
func TestSetTaskStatusRecordsWho(t *testing.T) {
ctx := context.Background()
st := newTestStore(t)
now := time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC)
res, err := st.CaptureTask(ctx, Task{Text: "выкинуть мусор", Source: "tap:voice", CreatedTs: now})
if err != nil {
t.Fatal(err)
}
if err := st.SetTaskStatus(ctx, res.ID, TaskDone, now.Add(time.Hour), "tap:voice"); err != nil {
t.Fatal(err)
}
got, err := st.LookupTask(ctx, res.ID)
if err != nil {
t.Fatal(err)
}
if got.ResolvedBy != "tap:voice" {
t.Errorf("resolved_by = %q, want tap:voice", got.ResolvedBy)
}
}
// The resolved history only grows; an unbounded read of it is a page that gets
// slower every month.
func TestListTasksIsBounded(t *testing.T) {
ctx := context.Background()
st := newTestStore(t)
now := time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC)
for i := 0; i < MaxTaskRows+5; i++ {
res, err := st.CaptureTask(ctx, Task{
Text: fmt.Sprintf("задача %d", i), Source: "tap:voice",
CreatedTs: now.Add(time.Duration(i) * time.Minute),
})
if err != nil {
t.Fatal(err)
}
if err := st.SetTaskStatus(ctx, res.ID, TaskDone, now.Add(time.Hour), "tap:web"); err != nil {
t.Fatal(err)
}
}
all, err := st.ListTasks(ctx, "")
if err != nil {
t.Fatal(err)
}
if len(all) != MaxTaskRows {
t.Fatalf("all = %d rows, want the %d-row bound", len(all), MaxTaskRows)
}
if all[0].Text != fmt.Sprintf("задача %d", MaxTaskRows+4) {
t.Errorf("first = %q, want the newest", all[0].Text)
}
}
func TestTaskBoardColumnsRoundTrip(t *testing.T) {
ctx := context.Background()
st := newTestStore(t)
now := time.Date(2026, 8, 5, 9, 0, 0, 0, time.UTC)
res, err := st.CaptureTask(ctx, Task{
Text: "оплатить интернет", Source: "tap:voice", CreatedTs: now,
DoneWhen: "квитанция оплачена", BlockedOn: "ent_kate",
})
if err != nil {
t.Fatal(err)
}
got, err := st.LookupTask(ctx, res.ID)
if err != nil {
t.Fatal(err)
}
if got.DoneWhen != "квитанция оплачена" || got.BlockedOn != "ent_kate" {
t.Fatalf("task = %+v, want both board columns back", got)
}
// Not one-way, unlike a status move: he sharpens the criterion, and the
// blocker clears when the person answers.
if err := st.SetTaskFields(ctx, res.ID, " пришло подтверждение ", ""); err != nil {
t.Fatal(err)
}
got, err = st.LookupTask(ctx, res.ID)
if err != nil {
t.Fatal(err)
}
if got.DoneWhen != "пришло подтверждение" {
t.Errorf("done_when = %q, want the trimmed rewrite", got.DoneWhen)
}
if got.BlockedOn != "" {
t.Errorf("blocked_on = %q, want it cleared", got.BlockedOn)
}
if err := st.SetTaskFields(ctx, 9999, "x", ""); !errors.Is(err, ErrTaskNotFound) {
t.Errorf("SetTaskFields on a missing row = %v, want ErrTaskNotFound", err)
}
}
func TestPromotingACandidateNeedsADefinitionOfDone(t *testing.T) {
ctx := context.Background()
st := newTestStore(t)
now := time.Date(2026, 8, 5, 9, 0, 0, 0, time.UTC)
res, err := st.CaptureTask(ctx, Task{
Text: "продлить домен", Source: "email:main", Status: TaskCandidate,
ExternalID: "msg-1:0", Evidence: "Domain expiring", CreatedTs: now,
})
if err != nil {
t.Fatal(err)
}
// A board row whose finish line nobody wrote can never leave the board.
if err := st.SetTaskStatus(ctx, res.ID, TaskOpen, now, "tap:web"); !errors.Is(err, ErrTaskNoDoneWhen) {
t.Fatalf("promotion with no criterion = %v, want ErrTaskNoDoneWhen", err)
}
// Dropping it stays legal — declining work does not need one.
dropped, err := st.CaptureTask(ctx, Task{
Text: "перезвонить в банк", Source: "email:main", Status: TaskCandidate,
ExternalID: "msg-2:0", CreatedTs: now,
})
if err != nil {
t.Fatal(err)
}
if err := st.SetTaskStatus(ctx, dropped.ID, TaskDropped, now, "tap:web"); err != nil {
t.Fatalf("dropping a candidate with no criterion: %v", err)
}
if err := st.SetTaskFields(ctx, res.ID, "домен продлён до 2027", ""); err != nil {
t.Fatal(err)
}
if err := st.SetTaskStatus(ctx, res.ID, TaskOpen, now, "tap:web"); err != nil {
t.Fatalf("promotion after writing a criterion: %v", err)
}
got, err := st.LookupTask(ctx, res.ID)
if err != nil {
t.Fatal(err)
}
if got.Status != TaskOpen {
t.Errorf("status = %q, want open", got.Status)
}
}
func TestEditTaskRewritesTheCaptureFields(t *testing.T) {
ctx := context.Background()
st := newTestStore(t)
now := time.Date(2026, 8, 5, 9, 0, 0, 0, time.UTC)
due := now.Add(48 * time.Hour)
res, err := st.CaptureTask(ctx, Task{Text: "купить малако", Source: "tap:voice", CreatedTs: now, Weight: 1})
if err != nil {
t.Fatal(err)
}
if err := st.EditTask(ctx, res.ID, " купить молоко ", &due, 3); err != nil {
t.Fatal(err)
}
got, err := st.LookupTask(ctx, res.ID)
if err != nil {
t.Fatal(err)
}
if got.Text != "купить молоко" || got.Weight != 3 || got.Due == nil || !got.Due.Equal(due) {
t.Fatalf("task = %+v, want the dictation typo fixed with the date and weight", got)
}
// Clearing the date has to be sayable, or an absent date and "remove the
// date" would be one argument.
if err := st.EditTask(ctx, res.ID, "купить молоко", nil, 3); err != nil {
t.Fatal(err)
}
if got, err = st.LookupTask(ctx, res.ID); err != nil {
t.Fatal(err)
} else if got.Due != nil {
t.Errorf("due = %v, want it cleared", got.Due)
}
// The dedupe key moved with the text: capturing the old wording is new work.
again, err := st.CaptureTask(ctx, Task{Text: "купить малако", Source: "tap:voice", CreatedTs: now})
if err != nil {
t.Fatal(err)
}
if !again.Created {
t.Error("the old normalised text must be free after the edit")
}
if err := st.EditTask(ctx, res.ID, "", nil, 0); !errors.Is(err, ErrTaskEmpty) {
t.Errorf("empty text = %v, want ErrTaskEmpty", err)
}
}
func TestEditTaskRefusesACollisionAndAResolvedRow(t *testing.T) {
ctx := context.Background()
st := newTestStore(t)
now := time.Date(2026, 8, 5, 9, 0, 0, 0, time.UTC)
first, err := st.CaptureTask(ctx, Task{Text: "купить молоко", Source: "tap:voice", CreatedTs: now})
if err != nil {
t.Fatal(err)
}
second, err := st.CaptureTask(ctx, Task{Text: "оплатить интернет", Source: "tap:voice", CreatedTs: now})
if err != nil {
t.Fatal(err)
}
// Two live rows carry two provenances and two capture times, so a merge
// would pick a winner for both with nobody asked.
if err := st.EditTask(ctx, second.ID, "Купить молоко!", nil, 0); !errors.Is(err, ErrTaskDuplicate) {
t.Errorf("collision = %v, want ErrTaskDuplicate", err)
}
// Editing a row to the text it already has is not a collision with itself.
if err := st.EditTask(ctx, first.ID, "купить молоко", nil, 2); err != nil {
t.Errorf("re-saving the same text: %v", err)
}
if err := st.SetTaskStatus(ctx, first.ID, TaskDone, now.Add(time.Hour), "tap:web"); err != nil {
t.Fatal(err)
}
// A resolved task's text is the record of what was finished.
if err := st.EditTask(ctx, first.ID, "купить кефир", nil, 0); !errors.Is(err, ErrTaskResolved) {
t.Errorf("editing a resolved task = %v, want ErrTaskResolved", err)
}
if err := st.EditTask(ctx, 9999, "что-то", nil, 0); !errors.Is(err, ErrTaskNotFound) {
t.Errorf("editing a missing row = %v, want ErrTaskNotFound", err)
}
}