Merge branch 'fix/g05' into fix/integrated
This commit is contained in:
@@ -160,6 +160,23 @@ ALTER TABLE reminders ADD COLUMN next_fire_ts INTEGER;`, // #2
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_tasks_live_norm ON tasks (norm) WHERE status IN ('candidate','open');
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks (status, created_ts DESC);`,
|
||||
|
||||
// #15 — external identity and resolution attribution for tasks.
|
||||
//
|
||||
// ext_id is the identity of the thing a derived task was extracted FROM
|
||||
// (message id plus the extracted span), and its unique index covers EVERY
|
||||
// row, not just the live ones. The live-only norm index is right for
|
||||
// voice, where him saying the errand again is the recurrence signal. It is
|
||||
// wrong for a mailbox: mavmaild is a read-only reader, nothing marks a
|
||||
// message read, so a task he already finished would be re-extracted from
|
||||
// the same immutable text on the next poll and land back on his list as a
|
||||
// fresh candidate, forever.
|
||||
//
|
||||
// resolved_by records which caller moved the task. resolved_ts said when
|
||||
// and never by what, so a wrong resolution left no trace at all.
|
||||
`ALTER TABLE tasks ADD COLUMN ext_id TEXT;
|
||||
ALTER TABLE tasks ADD COLUMN resolved_by TEXT NOT NULL DEFAULT '';
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_tasks_ext_id ON tasks (ext_id) WHERE ext_id IS NOT NULL;`,
|
||||
}
|
||||
|
||||
// migrate applies every migration with a number greater than the DB's current
|
||||
|
||||
+135
-34
@@ -48,16 +48,34 @@ const (
|
||||
//
|
||||
// Due is optional. Weight is an explicit importance hint (0 = none), which the
|
||||
// prioritiser reads; capture never invents one.
|
||||
// ExternalID is the identity of the thing this task was derived FROM — a
|
||||
// message id plus the extracted span, for a source that re-reads the same
|
||||
// immutable text forever. Empty for anything he stated himself.
|
||||
//
|
||||
// ResolvedBy names the caller that moved the task to its terminal state, in the
|
||||
// source vocabulary. Empty while the task is live.
|
||||
type Task struct {
|
||||
ID int64
|
||||
CreatedTs time.Time
|
||||
Text string
|
||||
Source string
|
||||
Evidence string
|
||||
ExternalID string
|
||||
Status string
|
||||
Due *time.Time
|
||||
Weight int
|
||||
ResolvedTs *time.Time
|
||||
ResolvedBy string
|
||||
}
|
||||
|
||||
// CaptureResult — what CaptureTask did. Created is a new row. Promoted is an
|
||||
// existing candidate this capture turned into open work: he stated out loud a
|
||||
// task Maven had only proposed, which is a confirmation, and the caller says so
|
||||
// instead of "уже в списке".
|
||||
type CaptureResult struct {
|
||||
ID int64
|
||||
Created bool
|
||||
Promoted bool
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -69,19 +87,49 @@ var (
|
||||
// liveTaskStatuses — the two statuses that count as outstanding work.
|
||||
var liveTaskStatuses = []string{TaskCandidate, TaskOpen}
|
||||
|
||||
// CaptureTask inserts a task, or returns the existing live task when the same
|
||||
// work is already outstanding. created reports which happened, so a caller can
|
||||
// tell the owner "уже в списке" instead of pretending it wrote something.
|
||||
// derivedSourcePrefixes — provenance that means "Maven read this somewhere",
|
||||
// as opposed to "he said it". A task from one of these is a candidate and
|
||||
// nothing else; see CaptureTask.
|
||||
var derivedSourcePrefixes = []string{"email:"}
|
||||
|
||||
// IsDerivedSource reports whether a task source means Maven inferred the task
|
||||
// from something she read rather than being told it.
|
||||
func IsDerivedSource(source string) bool {
|
||||
for _, p := range derivedSourcePrefixes {
|
||||
if strings.HasPrefix(source, p) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// CaptureTask inserts a task, or returns the existing one when the same work is
|
||||
// already there. The result says which happened, so a caller can tell the owner
|
||||
// "уже в списке" instead of pretending it wrote something.
|
||||
//
|
||||
// Dedupe is on the normalised text among LIVE rows only (see the partial unique
|
||||
// index in migration #14): a weekly errand can be captured again once the last
|
||||
// one is done, but a mail that gets re-read produces no second row. This is the
|
||||
// property the email intake depends on — it may call CaptureTask for every
|
||||
// message it extracts from, as often as it likes, without growing the list.
|
||||
func (s *Store) CaptureTask(ctx context.Context, t Task) (id int64, created bool, err error) {
|
||||
// Two dedupe keys, because voice and mail have different intake semantics:
|
||||
//
|
||||
// - ExternalID, unique over EVERY row whatever its status. A source that
|
||||
// re-reads the same immutable text forever must never resurrect work he has
|
||||
// already finished. This is the property the email intake depends on: it
|
||||
// may call CaptureTask for every message it extracts from, as often as it
|
||||
// likes, without growing the list.
|
||||
// - The normalised text among LIVE rows only (the partial unique index in
|
||||
// migration #14), for anything with no external identity. A weekly errand
|
||||
// captured again once the last one is done must produce a new row, because
|
||||
// him saying it again IS the recurrence signal.
|
||||
//
|
||||
// A capture with Status open over an existing candidate PROMOTES it. Stating
|
||||
// the work out loud is a confirmation, and leaving it a candidate would have
|
||||
// Maven read it back as something he never confirmed.
|
||||
//
|
||||
// A derived source may only ever capture a candidate. The doc on the intake
|
||||
// seam said "must NOT set Status open"; this is where that stops being an
|
||||
// honour system, so a compromised reader cannot file work he never reviewed.
|
||||
func (s *Store) CaptureTask(ctx context.Context, t Task) (CaptureResult, error) {
|
||||
text := strings.TrimSpace(t.Text)
|
||||
if text == "" {
|
||||
return 0, false, ErrTaskEmpty
|
||||
return CaptureResult{}, ErrTaskEmpty
|
||||
}
|
||||
status := t.Status
|
||||
if status == "" {
|
||||
@@ -90,7 +138,10 @@ func (s *Store) CaptureTask(ctx context.Context, t Task) (id int64, created bool
|
||||
if status != TaskCandidate && status != TaskOpen {
|
||||
// Capturing straight into a resolved state is meaningless — a task is
|
||||
// captured live and moved later.
|
||||
return 0, false, fmt.Errorf("%w: capture status %q", ErrTaskStatus, status)
|
||||
return CaptureResult{}, fmt.Errorf("%w: capture status %q", ErrTaskStatus, status)
|
||||
}
|
||||
if status == TaskOpen && IsDerivedSource(t.Source) {
|
||||
return CaptureResult{}, fmt.Errorf("%w: derived source %q may only capture a candidate", ErrTaskStatus, t.Source)
|
||||
}
|
||||
norm := NormalizeTaskText(text)
|
||||
created2 := t.CreatedTs
|
||||
@@ -101,31 +152,69 @@ func (s *Store) CaptureTask(ctx context.Context, t Task) (id int64, created bool
|
||||
if t.Due != nil {
|
||||
due = sql.NullInt64{Int64: t.Due.UnixMilli(), Valid: true}
|
||||
}
|
||||
var ext sql.NullString
|
||||
if e := strings.TrimSpace(t.ExternalID); e != "" {
|
||||
ext = sql.NullString{String: e, Valid: true}
|
||||
}
|
||||
|
||||
// Untargeted DO NOTHING: either unique index may be the one that fires, and
|
||||
// the lookup below sorts out which.
|
||||
res, err := s.db.ExecContext(ctx,
|
||||
`INSERT INTO tasks (created_ts, text, norm, source, evidence, status, due_ts, weight)
|
||||
VALUES (?,?,?,?,?,?,?,?)
|
||||
ON CONFLICT (norm) WHERE status IN ('candidate','open') DO NOTHING`,
|
||||
created2.UnixMilli(), text, norm, t.Source, t.Evidence, status, due, t.Weight)
|
||||
`INSERT INTO tasks (created_ts, text, norm, source, evidence, ext_id, status, due_ts, weight)
|
||||
VALUES (?,?,?,?,?,?,?,?,?)
|
||||
ON CONFLICT DO NOTHING`,
|
||||
created2.UnixMilli(), text, norm, t.Source, t.Evidence, ext, status, due, t.Weight)
|
||||
if err != nil {
|
||||
return 0, false, fmt.Errorf("capture task: %w", err)
|
||||
return CaptureResult{}, fmt.Errorf("capture task: %w", err)
|
||||
}
|
||||
if n, err := res.RowsAffected(); err != nil {
|
||||
return 0, false, fmt.Errorf("capture task: rows affected: %w", err)
|
||||
return CaptureResult{}, fmt.Errorf("capture task: rows affected: %w", err)
|
||||
} else if n > 0 {
|
||||
id, err := res.LastInsertId()
|
||||
if err != nil {
|
||||
return 0, false, fmt.Errorf("capture task: last insert id: %w", err)
|
||||
return CaptureResult{}, fmt.Errorf("capture task: last insert id: %w", err)
|
||||
}
|
||||
return id, true, nil
|
||||
return CaptureResult{ID: id, Created: true}, nil
|
||||
}
|
||||
|
||||
// Already live — hand back the row that won.
|
||||
existing, err := s.lookupLiveTaskByNorm(ctx, norm)
|
||||
if err != nil {
|
||||
return 0, false, err
|
||||
// Something already holds one of the keys. External identity first: that
|
||||
// row may be resolved, in which case the answer is "already handled", not a
|
||||
// new task.
|
||||
var existing Task
|
||||
if ext.Valid {
|
||||
existing, err = s.lookupTaskByExternalID(ctx, ext.String)
|
||||
if err != nil && !errors.Is(err, ErrTaskNotFound) {
|
||||
return CaptureResult{}, err
|
||||
}
|
||||
}
|
||||
return existing.ID, false, nil
|
||||
if existing.ID == 0 {
|
||||
existing, err = s.lookupLiveTaskByNorm(ctx, norm)
|
||||
if err != nil {
|
||||
return CaptureResult{}, err
|
||||
}
|
||||
}
|
||||
if status == TaskOpen && existing.Status == TaskCandidate {
|
||||
if err := s.SetTaskStatus(ctx, existing.ID, TaskOpen, created2, t.Source); err != nil {
|
||||
return CaptureResult{}, fmt.Errorf("capture task: promote candidate: %w", err)
|
||||
}
|
||||
return CaptureResult{ID: existing.ID, Promoted: true}, nil
|
||||
}
|
||||
return CaptureResult{ID: existing.ID}, nil
|
||||
}
|
||||
|
||||
// lookupTaskByExternalID finds a task by the identity of what it was derived
|
||||
// from, in ANY status. Resolved rows count: the whole point of the key is that
|
||||
// re-reading the mail that produced a finished task produces nothing.
|
||||
func (s *Store) lookupTaskByExternalID(ctx context.Context, ext string) (Task, error) {
|
||||
row := s.db.QueryRowContext(ctx, taskSelect+` WHERE ext_id = ?`, ext)
|
||||
t, err := scanTask(row)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return Task{}, ErrTaskNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return Task{}, fmt.Errorf("lookup task by external id: %w", err)
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// lookupLiveTaskByNorm finds the outstanding task with this normalised text.
|
||||
@@ -142,7 +231,7 @@ func (s *Store) lookupLiveTaskByNorm(ctx context.Context, norm string) (Task, er
|
||||
return t, nil
|
||||
}
|
||||
|
||||
const taskSelect = `SELECT id, created_ts, text, source, evidence, status, due_ts, weight, resolved_ts FROM tasks`
|
||||
const taskSelect = `SELECT id, created_ts, text, source, evidence, COALESCE(ext_id,''), status, due_ts, weight, resolved_ts, resolved_by FROM tasks`
|
||||
|
||||
// LookupTask returns one task by id.
|
||||
func (s *Store) LookupTask(ctx context.Context, id int64) (Task, error) {
|
||||
@@ -157,9 +246,15 @@ func (s *Store) LookupTask(ctx context.Context, id int64) (Task, error) {
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// ListTasks returns tasks in one status, newest first. An empty status returns
|
||||
// every row; "live" returns candidate + open, which is what every read path
|
||||
// that means "outstanding work" wants.
|
||||
// MaxTaskRows — the hard bound on one ListTasks read. The live set is a list a
|
||||
// person keeps by hand and never approaches this; the resolved set grows for as
|
||||
// long as the box runs, and an unbounded read of it is a page that gets slower
|
||||
// every month. Newest first, so the bound drops the oldest finished work.
|
||||
const MaxTaskRows = 500
|
||||
|
||||
// ListTasks returns tasks in one status, newest first, at most MaxTaskRows of
|
||||
// them. An empty status returns every row; "live" returns candidate + open,
|
||||
// which is what every read path that means "outstanding work" wants.
|
||||
func (s *Store) ListTasks(ctx context.Context, status string) ([]Task, error) {
|
||||
q := taskSelect
|
||||
var args []any
|
||||
@@ -172,7 +267,7 @@ func (s *Store) ListTasks(ctx context.Context, status string) ([]Task, error) {
|
||||
q += ` WHERE status = ?`
|
||||
args = append(args, status)
|
||||
}
|
||||
q += ` ORDER BY created_ts DESC, id DESC`
|
||||
q += fmt.Sprintf(` ORDER BY created_ts DESC, id DESC LIMIT %d`, MaxTaskRows)
|
||||
|
||||
rows, err := s.db.QueryContext(ctx, q, args...)
|
||||
if err != nil {
|
||||
@@ -201,8 +296,14 @@ func (s *Store) ListTasks(ctx context.Context, status string) ([]Task, error) {
|
||||
// ErrTaskNotFound-wrapped detail, the same one-way shape proposed_routines and
|
||||
// tools use: an answered question is not answered twice.
|
||||
//
|
||||
// Resolving frees the dedupe key, which is the point: the work can recur.
|
||||
func (s *Store) SetTaskStatus(ctx context.Context, id int64, status string, ts time.Time) error {
|
||||
// Resolving frees the NORM dedupe key, which is the point: the work can recur
|
||||
// when he says it again. It does not free an external identity — see
|
||||
// CaptureTask for why a re-read mailbox must not resurrect finished work.
|
||||
//
|
||||
// by names the caller making the move, in the source vocabulary ("tap:web",
|
||||
// "tap:voice"). It is recorded on the row, so a task that turns up resolved
|
||||
// says what resolved it.
|
||||
func (s *Store) SetTaskStatus(ctx context.Context, id int64, status string, ts time.Time, by string) error {
|
||||
var from []string
|
||||
switch status {
|
||||
case TaskOpen:
|
||||
@@ -222,9 +323,9 @@ func (s *Store) SetTaskStatus(ctx context.Context, id int64, status string, ts t
|
||||
resolved = sql.NullInt64{Int64: ts.UnixMilli(), Valid: true}
|
||||
}
|
||||
|
||||
q := `UPDATE tasks SET status = ?, resolved_ts = ? WHERE id = ? AND status IN (?` +
|
||||
q := `UPDATE tasks SET status = ?, resolved_ts = ?, resolved_by = ? WHERE id = ? AND status IN (?` +
|
||||
strings.Repeat(",?", len(from)-1) + `)`
|
||||
args := []any{status, resolved, id}
|
||||
args := []any{status, resolved, by, id}
|
||||
for _, f := range from {
|
||||
args = append(args, f)
|
||||
}
|
||||
@@ -271,7 +372,7 @@ func scanTask(sc scanner) (Task, error) {
|
||||
var t Task
|
||||
var created int64
|
||||
var due, resolved sql.NullInt64
|
||||
if err := sc.Scan(&t.ID, &created, &t.Text, &t.Source, &t.Evidence, &t.Status, &due, &t.Weight, &resolved); err != nil {
|
||||
if err := sc.Scan(&t.ID, &created, &t.Text, &t.Source, &t.Evidence, &t.ExternalID, &t.Status, &due, &t.Weight, &resolved, &t.ResolvedBy); err != nil {
|
||||
return Task{}, err
|
||||
}
|
||||
t.CreatedTs = time.UnixMilli(created).UTC()
|
||||
|
||||
+182
-27
@@ -3,6 +3,7 @@ package store
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
@@ -12,24 +13,24 @@ func TestCaptureTaskDedupesLiveWork(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
now := time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC)
|
||||
|
||||
id, created, err := st.CaptureTask(ctx, Task{Text: "купить молоко", Source: "tap:voice", CreatedTs: now})
|
||||
first, err := st.CaptureTask(ctx, Task{Text: "купить молоко", Source: "tap:voice", CreatedTs: now})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !created {
|
||||
if !first.Created {
|
||||
t.Fatal("first capture must create a row")
|
||||
}
|
||||
|
||||
// Same work, different casing and punctuation — one task, not two.
|
||||
again, created, err := st.CaptureTask(ctx, Task{Text: "Купить молоко!", Source: "email:kami", CreatedTs: now})
|
||||
again, err := st.CaptureTask(ctx, Task{Text: "Купить молоко!", Source: "tap:web", CreatedTs: now})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if created {
|
||||
if again.Created {
|
||||
t.Error("second capture of the same live work must not create a row")
|
||||
}
|
||||
if again != id {
|
||||
t.Errorf("dedupe returned id %d, want the existing %d", again, id)
|
||||
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")
|
||||
@@ -46,20 +47,22 @@ func TestCaptureTaskAfterDoneIsANewTask(t *testing.T) {
|
||||
st := newTestStore(t)
|
||||
now := time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC)
|
||||
|
||||
id, _, err := st.CaptureTask(ctx, Task{Text: "полить цветы", Source: "tap:voice", CreatedTs: now})
|
||||
first, err := st.CaptureTask(ctx, Task{Text: "полить цветы", Source: "tap:voice", CreatedTs: now})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.SetTaskStatus(ctx, id, TaskDone, now.Add(time.Hour)); err != nil {
|
||||
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.
|
||||
id2, created, err := st.CaptureTask(ctx, Task{Text: "полить цветы", Source: "tap:voice", CreatedTs: now.AddDate(0, 0, 7)})
|
||||
second, err := st.CaptureTask(ctx, Task{Text: "полить цветы", Source: "tap:voice", CreatedTs: now.AddDate(0, 0, 7)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !created || id2 == id {
|
||||
t.Fatalf("re-capture after done: created=%v id=%d (previous %d)", created, id2, id)
|
||||
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 {
|
||||
@@ -76,7 +79,7 @@ func TestCaptureTaskCandidateKeepsEvidence(t *testing.T) {
|
||||
now := time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC)
|
||||
due := now.Add(48 * time.Hour)
|
||||
|
||||
id, _, err := st.CaptureTask(ctx, Task{
|
||||
res, err := st.CaptureTask(ctx, Task{
|
||||
Text: "продлить страховку",
|
||||
Source: "email:kami",
|
||||
Evidence: "Re: страховой полис истекает",
|
||||
@@ -88,7 +91,7 @@ func TestCaptureTaskCandidateKeepsEvidence(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := st.LookupTask(ctx, id)
|
||||
got, err := st.LookupTask(ctx, res.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -114,22 +117,23 @@ func TestSetTaskStatusMovesOnceForwardOnly(t *testing.T) {
|
||||
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, CreatedTs: now})
|
||||
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); !errors.Is(err, ErrTaskNotFound) {
|
||||
if err := st.SetTaskStatus(ctx, cand, TaskDone, now, "tap:web"); !errors.Is(err, ErrTaskNotFound) {
|
||||
t.Errorf("candidate→done err = %v, want ErrTaskNotFound", err)
|
||||
}
|
||||
if err := st.SetTaskStatus(ctx, cand, TaskOpen, now); err != nil {
|
||||
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)); err != nil {
|
||||
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)); !errors.Is(err, ErrTaskNotFound) {
|
||||
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)
|
||||
@@ -147,14 +151,15 @@ func TestSetTaskStatusMovesOnceForwardOnly(t *testing.T) {
|
||||
func TestSetTaskStatusRejectsUnknownStatus(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
st := newTestStore(t)
|
||||
id, _, err := st.CaptureTask(ctx, Task{Text: "что-то", Source: "tap:web"})
|
||||
res, err := st.CaptureTask(ctx, Task{Text: "что-то", Source: "tap:web"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.SetTaskStatus(ctx, id, "candidate", time.Now()); !errors.Is(err, ErrTaskStatus) {
|
||||
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()); !errors.Is(err, ErrTaskStatus) {
|
||||
if err := st.SetTaskStatus(ctx, id, "urgent", time.Now(), "tap:web"); !errors.Is(err, ErrTaskStatus) {
|
||||
t.Errorf("→urgent err = %v, want ErrTaskStatus", err)
|
||||
}
|
||||
}
|
||||
@@ -162,7 +167,7 @@ func TestSetTaskStatusRejectsUnknownStatus(t *testing.T) {
|
||||
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) {
|
||||
if _, err := st.CaptureTask(ctx, Task{Text: " ", Source: "tap:voice"}); !errors.Is(err, ErrTaskEmpty) {
|
||||
t.Errorf("err = %v, want ErrTaskEmpty", err)
|
||||
}
|
||||
}
|
||||
@@ -172,10 +177,10 @@ func TestListTasksFiltersByStatus(t *testing.T) {
|
||||
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, TaskDone, now.Add(time.Hour)); err != nil {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -190,7 +195,7 @@ func TestListTasksFiltersByStatus(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(opens) != 1 || opens[0].ID != open1 {
|
||||
if len(opens) != 1 || opens[0].ID != open1.ID {
|
||||
t.Fatalf("open = %+v", opens)
|
||||
}
|
||||
all, err := st.ListTasks(ctx, "")
|
||||
@@ -219,3 +224,153 @@ func TestNormalizeTaskText(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user