Fundamentet under Kvasir, og det er værd at have uden ham. Intet kunne svare på "er denne mail allerede blevet til en opgave?". To læsere af samme postkasse — en anden assistent, en gentaget kørsel, et menneske — laver derfor to opgaver. Det er ikke teoretisk: en briefing lavede "Betal faktura fra Browns" to gange i samme kørsel. Opgaver har nu en `source_key`, navngivet med et rum foran: `gmail:thread:…`, `imap:<vært>:<mappe>:<uid>`. Et Gmail-tråd-id og et IMAP-uid ligner ikke hinanden, lige indtil den dag de kolliderer, og et kolon koster ingenting. Det betyder også, at den næste kilde ikke skal bruge en ny kolonne — pointen med en nøgle frem for en fremmednøgle. Der findes ingen messages-tabel at pege på, og det her skal virke uden. Et delvist unikt indeks er selve låsen, ikke en optimering: to agenter, der læser samme indbakke, SKAL være ude af stand til at lave to opgaver, og "skal være ude af stand til" er en betingelse frem for en aftale, nogen husker. Afgrænset til den, mailen kom til — to personer på samme instans, der begge modtager tråden, får hver sin opgave, og det er rigtigt. CreateTask svarer ErrDuplicate frem for at oprette nummer to, og begge mail-veje springer over i stedet for at fejle. IMAP-vejen tæller den stadig som læst — ellers rykker last_uid sig aldrig forbi den, og hver eneste kørsel fra da af standser samme sted. `TaskIDBySource` er spørgsmålet den anden vej, så en assistent, der ikke ejer triagen, kan se, at mailen er håndteret. Slettede opgaver tæller med: en opgave, der blev lavet af en mail og smidt væk, er også et svar.
110 lines
3.7 KiB
Go
110 lines
3.7 KiB
Go
package store
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"testing"
|
|
)
|
|
|
|
// One mail, one task — however many readers there are.
|
|
//
|
|
// Nothing could answer "has this already become a task?" before, so a second
|
|
// reader of the same mailbox made a second task. That is not hypothetical: a
|
|
// briefing run produced "Betal faktura fra Browns" twice in one pass, and a
|
|
// separate assistant reading the same inbox does the same thing every sweep.
|
|
func TestOneMailBecomesOneTask(t *testing.T) {
|
|
db := openTest(t)
|
|
ctx := context.Background()
|
|
|
|
user := &User{Email: "kristian@example.dk", Name: "Kristian", PasswordHash: "x"}
|
|
if err := db.CreateUser(ctx, user, "Indbakke"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
inbox := newProject(t, db, user.ID)
|
|
|
|
const thread = "gmail:thread:18f2cabc"
|
|
first := &Task{ProjectID: inbox, Content: "Svar Mette", CreatedBy: user.ID, SourceKey: thread}
|
|
if err := db.CreateTask(ctx, first, nil); err != nil {
|
|
t.Fatalf("first task: %v", err)
|
|
}
|
|
|
|
// The same thread again — a rerun, or somebody else's assistant.
|
|
second := &Task{ProjectID: inbox, Content: "Svar Mette", CreatedBy: user.ID, SourceKey: thread}
|
|
err := db.CreateTask(ctx, second, nil)
|
|
if !errors.Is(err, ErrDuplicate) {
|
|
t.Fatalf("second task for the same thread: err = %v, want ErrDuplicate", err)
|
|
}
|
|
|
|
// And the question that makes the other agent safe.
|
|
id, err := db.TaskIDBySource(ctx, user.ID, thread)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if id != first.ID {
|
|
t.Errorf("TaskIDBySource = %q, want %q", id, first.ID)
|
|
}
|
|
if id, err := db.TaskIDBySource(ctx, user.ID, "gmail:thread:aldrig-set"); err != nil || id != "" {
|
|
t.Errorf("an unseen thread answered %q, %v", id, err)
|
|
}
|
|
}
|
|
|
|
// A task somebody typed has no source, and there can be any number of those.
|
|
// The index is partial for exactly this reason — without the WHERE clause the
|
|
// second hand-written task in the database would be refused.
|
|
func TestTasksWithoutASourceDoNotCollide(t *testing.T) {
|
|
db := openTest(t)
|
|
ctx := context.Background()
|
|
|
|
user := &User{Email: "kristian@example.dk", Name: "Kristian", PasswordHash: "x"}
|
|
if err := db.CreateUser(ctx, user, "Indbakke"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
inbox := newProject(t, db, user.ID)
|
|
|
|
for i, name := range []string{"køb kaffe", "ring til Andreas", "hent pakken"} {
|
|
if err := db.CreateTask(ctx, &Task{ProjectID: inbox, Content: name, CreatedBy: user.ID}, nil); err != nil {
|
|
t.Fatalf("task %d (%s): %v", i, name, err)
|
|
}
|
|
}
|
|
if id, err := db.TaskIDBySource(ctx, user.ID, ""); err != nil || id != "" {
|
|
t.Errorf("an empty source key answered %q, %v", id, err)
|
|
}
|
|
}
|
|
|
|
// Two people on one instance, each with their own mailbox. The same thread id
|
|
// reaching both of them is two tasks, correctly — the key is scoped to whoever
|
|
// the mail arrived for.
|
|
func TestTheSameThreadForTwoPeopleIsTwoTasks(t *testing.T) {
|
|
db := openTest(t)
|
|
ctx := context.Background()
|
|
|
|
var ids []string
|
|
for _, email := range []string{"kristian@example.dk", "andreas@example.dk"} {
|
|
u := &User{Email: email, Name: email, PasswordHash: "x"}
|
|
if err := db.CreateUser(ctx, u, "Indbakke"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
inbox := newProject(t, db, u.ID)
|
|
task := &Task{ProjectID: inbox, Content: "Fælles tråd", CreatedBy: u.ID,
|
|
SourceKey: "gmail:thread:delt"}
|
|
if err := db.CreateTask(ctx, task, nil); err != nil {
|
|
t.Fatalf("%s: %v", email, err)
|
|
}
|
|
ids = append(ids, task.ID)
|
|
}
|
|
if ids[0] == ids[1] {
|
|
t.Error("the two people got the same task")
|
|
}
|
|
}
|
|
|
|
// A project to hang the tasks on. The tests are about the source key, not about
|
|
// where a task lives.
|
|
func newProject(t *testing.T, db *DB, ownerID string) string {
|
|
t.Helper()
|
|
p := &Project{OwnerID: ownerID, Name: "Indbakke"}
|
|
if err := db.CreateProject(context.Background(), p); err != nil {
|
|
t.Fatalf("create project: %v", err)
|
|
}
|
|
return p.ID
|
|
}
|