Files
verdande/internal/store/source_test.go
T
Kristian Wind 3e52eea2da
CI / OpenAPI is valid (push) Successful in 15s
CI / End-to-end (push) Successful in 9m46s
CI / Docker image builds (push) Successful in 1m46s
CI / Go (push) Failing after 15m23s
Release / Build and publish image (push) Failing after 5s
feat(kvasir): én mail bliver til én opgave, uanset hvem der læser den
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.
2026-08-22 09:19:41 +02:00

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
}