496559c9dd
Migration #22 adds done_when and blocked_on to tasks, both NOT NULL DEFAULT ''. "He has not written one" and "there is nothing to write" are the same state here, so no caller has to tell NULL from empty. blocked_on is a canonical Nexus entity id, never a name. It names a person and identity lives in Nexus, so free text here would be a second answer to a question Nexus already owns. The caller resolves before it writes. Both columns round-trip through ipc.TaskAPI: on ipc.Task, settable at intake through CaptureTaskReq, and writable afterwards through the new SetTaskFields, which is deliberately not one-way — he may sharpen a criterion, and a blocker clears when the person answers. SetTaskStatus now refuses candidate → open when done_when is empty (ErrTaskNoDoneWhen, mapped across the wire), the same refusal ParseTaskCapture makes for a capture marker with nothing after it: confirming work whose finish line nobody wrote is how a board fills with rows that can never leave it. Dropping such a candidate stays legal, and the /tasks confirm button now says what is missing instead of surfacing a not-found. One caller skips the gate. CaptureTask promoting a candidate he stated out loud would otherwise be denied intake rather than asked for a criterion, and a direct open capture never carried one either. The gate belongs to the deliberate promotion on /tasks, where V-511 puts a form.
468 lines
15 KiB
Go
468 lines
15 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)
|
|
}
|
|
}
|