190 lines
6.5 KiB
Go
190 lines
6.5 KiB
Go
package httpapi
|
|||
|
|
|
||
|
|
import (
|
||
|
|
"encoding/json"
|
||
|
|
"net/http"
|
||
|
|
"net/http/httptest"
|
||
|
|
"testing"
|
||
|
|
)
|
||
|
|
|
||
|
|
// fakeModel er en OpenAI-formet endpoint, der svarer med det, prøven har brug for.
|
||
|
|
//
|
||
|
|
// Den rigtige vej hele vejen igennem: indstillingerne, prompten, parsningen af
|
||
|
|
// svaret og skrivningen bagefter. Det, der er værd at prøve her, er ikke om en
|
||
|
|
// model kan svare — det er, hvad der sker med svaret, når den har svaret noget.
|
||
|
|
func fakeModel(t *testing.T, reply string) string {
|
||
|
|
t.Helper()
|
||
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
|
|
w.Header().Set("Content-Type", "application/json")
|
||
|
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||
|
|
"choices": []map[string]any{
|
||
|
|
{"message": map[string]string{"content": reply}},
|
||
|
|
},
|
||
|
|
})
|
||
|
|
}))
|
||
|
|
t.Cleanup(srv.Close)
|
||
|
|
return srv.URL
|
||
|
|
}
|
||
|
|
|
||
|
|
// useModel peger kontoen på den falske model. Kun en administrator må pege AI'en
|
||
|
|
// på en adresse i det private net, og bootstrap-kontoen er en.
|
||
|
|
func (ts *testServer) useModel(t *testing.T, url string) {
|
||
|
|
t.Helper()
|
||
|
|
resp, _ := ts.do(t, "PUT", "/api/v1/ai/settings", map[string]any{
|
||
|
|
"provider": "compatible", "base_url": url, "model": "prøvemodel",
|
||
|
|
})
|
||
|
|
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent {
|
||
|
|
t.Fatalf("gem ai-indstillinger: %d", resp.StatusCode)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Forslaget peger tilbage på den opgave, det handler om — også når modellen
|
||
|
|
// springer en over. Uden nummeret ville "sæt en dato på den her" lande på en
|
||
|
|
// anden opgave end den, den blev skrevet om.
|
||
|
|
func TestTidyingTheInboxSuggestsPerTask(t *testing.T) {
|
||
|
|
ts := newTestServer(t)
|
||
|
|
ts.bootstrap(t)
|
||
|
|
ts.useModel(t, fakeModel(t, `[
|
||
|
|
{"line": "2. Ring til Anders i morgen p1", "why": "der står et navn og et telefonnummer"}
|
||
|
|
]`))
|
||
|
|
|
||
|
|
first := ts.quickTask(t, "noget som helst")
|
||
|
|
second := ts.quickTask(t, "anders 12345678")
|
||
|
|
|
||
|
|
resp, out := ts.do(t, "POST", "/api/v1/ai/inbox/tidy", nil)
|
||
|
|
if resp.StatusCode != http.StatusOK {
|
||
|
|
t.Fatalf("ryd op: %d %v", resp.StatusCode, out)
|
||
|
|
}
|
||
|
|
list, _ := out["suggestions"].([]any)
|
||
|
|
if len(list) != 1 {
|
||
|
|
t.Fatalf("forslag = %v, want ét", list)
|
||
|
|
}
|
||
|
|
sug := list[0].(map[string]any)
|
||
|
|
if sug["task_id"] != second {
|
||
|
|
t.Errorf("forslaget peger på %v, want den anden opgave (%v)", sug["task_id"], second)
|
||
|
|
}
|
||
|
|
if sug["line"] != "Ring til Anders i morgen p1" {
|
||
|
|
t.Errorf("nummeret blev ikke taget af linjen: %v", sug["line"])
|
||
|
|
}
|
||
|
|
if sug["why"] == "" {
|
||
|
|
t.Error("forslaget siger ikke hvorfor")
|
||
|
|
}
|
||
|
|
_ = first
|
||
|
|
}
|
||
|
|
|
||
|
|
// Et ja skriver forslaget ind i opgaven — gennem den samme parser som alt andet,
|
||
|
|
// så "i morgen" betyder det samme her som i feltet øverst i fladen.
|
||
|
|
func TestAcceptingASuggestionWritesItIntoTheTask(t *testing.T) {
|
||
|
|
ts := newTestServer(t)
|
||
|
|
ts.bootstrap(t)
|
||
|
|
ts.useModel(t, fakeModel(t, `[]`))
|
||
|
|
taskID := ts.quickTask(t, "anders")
|
||
|
|
|
||
|
|
resp, out := ts.do(t, "POST", "/api/v1/ai/inbox/tidy/apply", map[string]any{
|
||
|
|
"task_id": taskID, "line": "Ring til Anders i morgen p1",
|
||
|
|
})
|
||
|
|
if resp.StatusCode != http.StatusOK {
|
||
|
|
t.Fatalf("accepter: %d %v", resp.StatusCode, out)
|
||
|
|
}
|
||
|
|
if out["content"] != "Ring til Anders" {
|
||
|
|
t.Errorf("indhold = %v", out["content"])
|
||
|
|
}
|
||
|
|
if out["priority"] != float64(1) {
|
||
|
|
t.Errorf("prioritet = %v, want 1", out["priority"])
|
||
|
|
}
|
||
|
|
if out["due_date"] == nil || out["due_date"] == "" {
|
||
|
|
t.Errorf("datoen blev ikke sat: %v", out["due_date"])
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Et projekt, modellen har fundet på, flytter ingenting. Den fik listen over dem,
|
||
|
|
// der findes, og et navn uden for den er et gæt — et gæt må ikke kunne flytte en
|
||
|
|
// opgave hen, hvor ejeren ikke leder efter den.
|
||
|
|
func TestAnInventedProjectDoesNotMoveTheTask(t *testing.T) {
|
||
|
|
ts := newTestServer(t)
|
||
|
|
ts.bootstrap(t)
|
||
|
|
ts.useModel(t, fakeModel(t, `[]`))
|
||
|
|
taskID := ts.quickTask(t, "anders")
|
||
|
|
|
||
|
|
_, before := ts.do(t, "GET", "/api/v1/tasks/"+taskID, nil)
|
||
|
|
resp, after := ts.do(t, "POST", "/api/v1/ai/inbox/tidy/apply", map[string]any{
|
||
|
|
"task_id": taskID, "line": "Ring til Anders #EtProjektDerIkkeFindes",
|
||
|
|
})
|
||
|
|
if resp.StatusCode != http.StatusOK {
|
||
|
|
t.Fatalf("accepter: %d", resp.StatusCode)
|
||
|
|
}
|
||
|
|
if after["project_id"] != before["project_id"] {
|
||
|
|
t.Errorf("opgaven blev flyttet til %v (var %v)", after["project_id"], before["project_id"])
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// En note bliver ikke rørt, når der bliver lavet opgaver ud af den. En tekst, et
|
||
|
|
// program skriver i uden at blive bedt om det, er en tekst, man holder op med at
|
||
|
|
// stole på.
|
||
|
|
func TestTasksFromANoteLeaveTheNoteAlone(t *testing.T) {
|
||
|
|
ts := newTestServer(t)
|
||
|
|
ts.bootstrap(t)
|
||
|
|
ts.useModel(t, fakeModel(t, `[
|
||
|
|
{"line": "Send prisliste til Anders på fredag", "why": "“jeg sender prislisten fredag”"}
|
||
|
|
]`))
|
||
|
|
|
||
|
|
body := "# Møde med Anders\njeg sender prislisten fredag"
|
||
|
|
noteID := ts.createNote(t, body)
|
||
|
|
|
||
|
|
resp, out := ts.do(t, "POST", "/api/v1/ai/notes/"+noteID+"/actions", nil)
|
||
|
|
if resp.StatusCode != http.StatusOK {
|
||
|
|
t.Fatalf("find opgaver: %d %v", resp.StatusCode, out)
|
||
|
|
}
|
||
|
|
list, _ := out["suggestions"].([]any)
|
||
|
|
if len(list) != 1 {
|
||
|
|
t.Fatalf("forslag = %v, want ét", list)
|
||
|
|
}
|
||
|
|
line := list[0].(map[string]any)["line"].(string)
|
||
|
|
|
||
|
|
resp, task := ts.do(t, "POST", "/api/v1/ai/notes/"+noteID+"/actions/apply",
|
||
|
|
map[string]any{"line": line})
|
||
|
|
if resp.StatusCode != http.StatusCreated {
|
||
|
|
t.Fatalf("accepter: %d %v", resp.StatusCode, task)
|
||
|
|
}
|
||
|
|
if task["content"] != "Send prisliste til Anders" {
|
||
|
|
t.Errorf("opgave = %v", task["content"])
|
||
|
|
}
|
||
|
|
// Opgaven bærer, hvor den kom fra, som et notelink.
|
||
|
|
if task["description"] != "[[Møde med Anders]]" {
|
||
|
|
t.Errorf("beskrivelse = %v, want et link til noten", task["description"])
|
||
|
|
}
|
||
|
|
|
||
|
|
_, note := ts.do(t, "GET", "/api/v1/notes/"+noteID, nil)
|
||
|
|
if note["body"] != body {
|
||
|
|
t.Errorf("noten blev rettet:\n%v", note["body"])
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Uden en model er funktionen slået fra — ikke i stykker. Svaret skal kunne
|
||
|
|
// kendes fra en fejl, så fladen kan sige "læg din nøgle ind" frem for "der skete
|
||
|
|
// noget".
|
||
|
|
func TestSuggestionsAreOffWithoutAModel(t *testing.T) {
|
||
|
|
ts := newTestServer(t)
|
||
|
|
ts.bootstrap(t)
|
||
|
|
|
||
|
|
for _, path := range []string{"/api/v1/ai/inbox/tidy"} {
|
||
|
|
if resp, _ := ts.do(t, "POST", path, nil); resp.StatusCode != http.StatusConflict {
|
||
|
|
t.Errorf("%s gav %d, want 409", path, resp.StatusCode)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// quickTask lægger en linje i indbakken gennem den samme vej som feltet øverst.
|
||
|
|
func (ts *testServer) quickTask(t *testing.T, text string) string {
|
||
|
|
t.Helper()
|
||
|
|
resp, out := ts.do(t, "POST", "/api/v1/tasks/quick-add", map[string]any{"text": text})
|
||
|
|
if resp.StatusCode != http.StatusCreated {
|
||
|
|
t.Fatalf("quickadd %q: %d %v", text, resp.StatusCode, out)
|
||
|
|
}
|
||
|
|
id, _ := out["id"].(string)
|
||
|
|
if id == "" {
|
||
|
|
t.Fatalf("quickadd gav intet id: %v", out)
|
||
|
|
}
|
||
|
|
return id
|
||
|
|
}
|