Files
verdande/internal/httpapi/notes_test.go
T
Kristian WindandClaude Opus 5 8aad7c13b0
Release / Build and publish image (push) Failing after 9m55s
perf(notes): listen sendte hele noter for at vise halvfems tegn af dem
Chrome frøs på notesiden, og der var to grunde.

**Nyttelasten.** Listen viser en titel og omkring halvfems tegn. Den fik hele
noter for at gøre det: med tolv hundrede blev svaret 3,9 MB, hvoraf otteoghalvfems
procent var brødtekst, ingen ville læse på den skærm — hentet, tolket og holdt i
hukommelsen hver gang siden blev åbnet. Nu 763 KB.

Kroppen tømmes frem for at blive forkortet, og det er den vigtige halvdel. Et felt,
der hedder `body` og indeholder et *stykke* af kroppen, er en fælde: editoren ville
åbne det, pausen ville gemme det, og noten ville være skåret ned til sit eget
uddrag af ingenting andet end at være blevet kigget på. Tom er en form, klienten
kan se efter — og den gør det og henter hele noten, når den åbner en.

Det kostede en adskillelse mere: hvilken række der er *valgt*, og hvilken note der
er *indlæst*, er nu to ting. Editoren indlæser sin tekst, når notens id skifter, så
hvis den fik uddraget først og hele noten bagefter, ville id'et være det samme
begge gange og den anden aldrig blive vist. Man ville sidde med en tom note, der
lige havde haft en titel.

**Tegningen.** Alle tolv hundrede rækker fik layout og maling, også de tusind
ingen kan se: 2,1 sekunder til første tegning. `content-visibility: auto` lader
browseren springe det over og tage det, når man ruller derhen — 0,95 sekunder, og
uden en eneste linje JavaScript, ingen rullematematik og ingen liste, der kan komme
ud af trit med sig selv.

**Og søgefeltet.** Det var en pille med et forstørrelsesglas tegnet ind som et
baggrundsbillede med farven skrevet i hånden — #8b918d, uanset tema — hvilket
gjorde det til det ene sted på siden, der ikke fulgte resten. Nu samme form som
søgefeltet i toppen, et rigtigt ikon i `currentColor`, en ryd-knap og et antal
fundne.

Det havde heller ingen pause: hver tast sendte en søgning gennem tolv hundrede
noter, så "kaffe" var fem forespørgsler, hvoraf de fire var forældede inden de kom
tilbage — og svarene kunne lande i den forkerte rækkefølge. Nu én.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-20 10:36:35 +02:00

374 lines
12 KiB
Go

package httpapi
import (
"archive/zip"
"bytes"
"encoding/json"
"io"
"mime/multipart"
"net/http"
"strings"
"testing"
)
// A note in a project is the project's; a note in no project is its author's.
// Both answers come from the project's own roles rather than from a rule notes
// invented for themselves, which is the point of filing them there.
func TestANoteIsNotReadableByJustAnybody(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
resp, body := ts.do(t, "POST", "/api/v1/notes", map[string]any{
"body": "Noget privat om #Firma",
})
if resp.StatusCode != http.StatusCreated {
t.Fatalf("create: %d %v", resp.StatusCode, body)
}
id, _ := body["id"].(string)
if id == "" {
t.Fatal("no id came back")
}
// The tags were read out of the text on the way in, without being asked for.
links, _ := body["links"].([]any)
if len(links) != 1 {
t.Fatalf("the note recorded %v", body["links"])
}
// Somebody else gets the same answer as for a note that does not exist, so an
// id cannot be probed for existence.
other := ts.newUser(t, "[email protected]", "Anden")
resp, _ = other.do(t, "GET", "/api/v1/notes/"+id, nil)
if resp.StatusCode != http.StatusNotFound {
t.Errorf("a stranger got %d for somebody else's note, want 404", resp.StatusCode)
}
}
// Searching must not become a way to read what you cannot open. The filter is
// applied after the index, and this is what says so.
func TestSearchDoesNotLeakNotesYouCannotRead(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
if resp, body := ts.do(t, "POST", "/api/v1/notes", map[string]any{
"body": "Hemmeligt kodeord til hjemmesiden",
}); resp.StatusCode != http.StatusCreated {
t.Fatalf("create: %d %v", resp.StatusCode, body)
}
_, mine := ts.do(t, "GET", "/api/v1/notes?q=hemmeligt", nil)
if got := len(mine["notes"].([]any)); got != 1 {
t.Fatalf("the author found %d of their own notes", got)
}
other := ts.newUser(t, "[email protected]", "Anden")
_, theirs := other.do(t, "GET", "/api/v1/notes?q=hemmeligt", nil)
if got := len(theirs["notes"].([]any)); got != 0 {
t.Errorf("a stranger's search returned %d notes", got)
}
}
// The backwards question, which is what makes notes worth linking at all.
func TestANoteIsFoundFromTheTaskItMentions(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
_, task := ts.do(t, "POST", "/api/v1/tasks/quick-add", map[string]any{
"text": "ring til Anders",
})
taskID, _ := task["id"].(string)
if taskID == "" {
t.Fatalf("no task id: %v", task)
}
if resp, _ := ts.do(t, "POST", "/api/v1/notes", map[string]any{
"body": "Aftalt at han ringer først, se /opgave/" + taskID,
}); resp.StatusCode != http.StatusCreated {
t.Fatal("the note was not written")
}
_, found := ts.do(t, "GET", "/api/v1/notes/linking/task/"+taskID, nil)
notes, _ := found["notes"].([]any)
if len(notes) != 1 {
t.Fatalf("%d notes point at the task, want 1", len(notes))
}
}
// Editing the text moves the links with it. A link that outlives its mention
// claims a connection that is not there.
func TestChangingANoteChangesWhatItPointsAt(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
_, note := ts.do(t, "POST", "/api/v1/notes", map[string]any{"body": "Om #Firma"})
id, _ := note["id"].(string)
_, before := ts.do(t, "GET", "/api/v1/notes/linking/project/Firma", nil)
if len(before["notes"].([]any)) != 1 {
t.Fatal("the first save recorded nothing")
}
if resp, _ := ts.do(t, "PATCH", "/api/v1/notes/"+id, map[string]any{
"body": "Om #Regnskab i stedet",
}); resp.StatusCode != http.StatusOK {
t.Fatal("the note was not changed")
}
_, after := ts.do(t, "GET", "/api/v1/notes/linking/project/Firma", nil)
if got := len(after["notes"].([]any)); got != 0 {
t.Errorf("%d notes still point at Firma", got)
}
}
// The export is the promise the whole design was arranged around: the note on
// disk is already the file you would export, so nothing is converted on the way
// out and nothing can be lost in the conversion.
func TestNotesExportAsMarkdownFiles(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
bodies := []string{
"# Møde med Anders\n\nHan vil gerne have **kaffe** hver uge.",
"Slash/i/titlen\n\nog noget tekst",
"# Møde med Anders\n\nen anden note med samme titel",
}
for _, body := range bodies {
if resp, _ := ts.do(t, "POST", "/api/v1/notes", map[string]any{"body": body}); resp.StatusCode != http.StatusCreated {
t.Fatalf("create: %d", resp.StatusCode)
}
}
req, _ := http.NewRequest("GET", ts.URL+"/api/v1/export/notes.zip", nil)
req.Header.Set("Sec-Fetch-Site", "same-origin")
for _, c := range ts.client.Jar.Cookies(mustParse(t, ts.URL)) {
req.AddCookie(c)
}
resp, err := ts.client.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("export: status %d", resp.StatusCode)
}
if ct := resp.Header.Get("Content-Type"); ct != "application/zip" {
t.Errorf("content type is %q", ct)
}
raw, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatal(err)
}
zr, err := zip.NewReader(bytes.NewReader(raw), int64(len(raw)))
if err != nil {
t.Fatalf("the archive does not open: %v", err)
}
if len(zr.File) != 3 {
t.Fatalf("the archive holds %d files, want 3", len(zr.File))
}
byName := map[string]string{}
for _, f := range zr.File {
r, err := f.Open()
if err != nil {
t.Fatal(err)
}
text, _ := io.ReadAll(r)
r.Close()
byName[f.Name] = string(text)
}
// Exactly what was stored, byte for byte. A converted export is an export that
// can be wrong.
if got := byName["Møde med Anders.md"]; got != bodies[0] {
t.Errorf("the note came out as %q", got)
}
// A slash in a title would make a directory, and on Windows it is refused
// outright.
if _, ok := byName["Slash-i-titlen.md"]; !ok {
t.Errorf("the awkward title became %v", keysOf(byName))
}
// Two notes with the same title are two notes; one entry twice is one lost.
if _, ok := byName["Møde med Anders (2).md"]; !ok {
t.Errorf("the second note of the same name is missing: %v", keysOf(byName))
}
}
func keysOf(m map[string]string) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
return out
}
// Out and back in again. If an export cannot be imported, it is not an export —
// it is a file somebody hopes will be readable later.
func TestNotesSurviveTheRoundTripThroughAZip(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
var archive bytes.Buffer
zw := zip.NewWriter(&archive)
files := map[string]string{
"Møde med Anders.md": "# Møde med Anders\n\nHan vil have **kaffe** hver uge. #Firma",
"noget andet.md": "Løse tanker\n\nog en linje til",
// A zip made on a Mac carries these beside every file. Importing them would
// double the whole library, with each copy full of binary rubbish.
"__MACOSX/._Møde med Anders.md": "\x00\x05\x16\x07binært",
"billede.png": "ikke en note",
"tom.md": " \n ",
}
for name, body := range files {
f, err := zw.Create(name)
if err != nil {
t.Fatal(err)
}
if _, err := io.WriteString(f, body); err != nil {
t.Fatal(err)
}
}
if err := zw.Close(); err != nil {
t.Fatal(err)
}
var form bytes.Buffer
mw := multipart.NewWriter(&form)
part, _ := mw.CreateFormFile("file", "noter.zip")
part.Write(archive.Bytes())
mw.Close()
req, _ := http.NewRequest("POST", ts.URL+"/api/v1/notes/import", &form)
req.Header.Set("Content-Type", mw.FormDataContentType())
req.Header.Set("Sec-Fetch-Site", "same-origin")
for _, c := range ts.client.Jar.Cookies(mustParse(t, ts.URL)) {
req.AddCookie(c)
}
resp, err := ts.client.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("import: status %d", resp.StatusCode)
}
var out struct{ Created, Skipped int }
json.NewDecoder(resp.Body).Decode(&out)
if out.Created != 2 {
t.Fatalf("imported %d notes, want the 2 real ones", out.Created)
}
// The title came from the first line, not from the filename — which is the same
// rule as everywhere else, and the reason renaming a file cannot rename a note.
_, listed := ts.do(t, "GET", "/api/v1/notes", nil)
titles := map[string]bool{}
for _, raw := range listed["notes"].([]any) {
titles[raw.(map[string]any)["title"].(string)] = true
}
if !titles["Møde med Anders"] || !titles["Løse tanker"] {
t.Errorf("the titles came out as %v", titles)
}
// And the #tag was read on the way in, so an imported note lands on its project
// exactly as a typed one does.
_, linked := ts.do(t, "GET", "/api/v1/notes/linking/project/Firma", nil)
if len(linked["notes"].([]any)) != 1 {
t.Errorf("the imported note did not link to its project")
}
}
// A note from Apple Notes is full of photographs. One that arrives as words alone
// is not that note — it is a summary of it.
func TestAnImportedNoteKeepsItsPictures(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
var archive bytes.Buffer
zw := zip.NewWriter(&archive)
write := func(name, body string) {
f, err := zw.Create(name)
if err != nil {
t.Fatal(err)
}
if _, err := io.WriteString(f, body); err != nil {
t.Fatal(err)
}
}
write("noter/Kvittering.md", "# Kvittering\n\nHer er den:\n\n![](vedhaeftninger/bon.png)\n\nog resten af teksten")
write("noter/vedhaeftninger/bon.png", "\x89PNG\r\n\x1a\nlad som om")
// Another note in the same archive, which must not inherit the first one's
// picture: an archive is one folder for many notes.
write("noter/Uden billede.md", "Uden billede\n\nbare tekst")
if err := zw.Close(); err != nil {
t.Fatal(err)
}
var form bytes.Buffer
mw := multipart.NewWriter(&form)
part, _ := mw.CreateFormFile("file", "noter.zip")
part.Write(archive.Bytes())
mw.Close()
req, _ := http.NewRequest("POST", ts.URL+"/api/v1/notes/import", &form)
req.Header.Set("Content-Type", mw.FormDataContentType())
req.Header.Set("Sec-Fetch-Site", "same-origin")
for _, c := range ts.client.Jar.Cookies(mustParse(t, ts.URL)) {
req.AddCookie(c)
}
resp, err := ts.client.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
var out struct{ Created, Skipped, Files int }
json.NewDecoder(resp.Body).Decode(&out)
if out.Created != 2 || out.Files != 1 {
t.Fatalf("imported %d notes and %d files, want 2 and 1", out.Created, out.Files)
}
// The list carries a preview, not the whole note — see briefly(). The body is
// fetched by id, which is what the interface does when it opens one.
_, listed := ts.do(t, "GET", "/api/v1/notes?q=kvittering", nil)
notes := listed["notes"].([]any)
if len(notes) != 1 {
t.Fatalf("found %d notes", len(notes))
}
noteID := notes[0].(map[string]any)["id"].(string)
if got := notes[0].(map[string]any)["body"]; got != nil && got != "" {
t.Errorf("the list carried a body: %v — an editor handed that would save the truncation", got)
}
_, whole := ts.do(t, "GET", "/api/v1/notes/"+noteID, nil)
body := whole["body"].(string)
// The link points at the file where it now lives, not at a path inside an
// archive nobody kept.
if strings.Contains(body, "vedhaeftninger/bon.png") {
t.Errorf("the link still points into the archive: %q", body)
}
if !strings.Contains(body, "/api/v1/attachments/") {
t.Errorf("the picture was not attached: %q", body)
}
// And it is still where it was written, in the middle of the note.
if !strings.HasPrefix(body, "# Kvittering") || !strings.Contains(body, "og resten af teksten") {
t.Errorf("the note was rearranged: %q", body)
}
// The picture is really there and really readable.
id := body[strings.Index(body, "/api/v1/attachments/")+len("/api/v1/attachments/"):]
id = strings.SplitN(id, ")", 2)[0]
got, _ := ts.do(t, "GET", "/api/v1/attachments/"+id, nil)
if got.StatusCode != http.StatusOK {
t.Errorf("the attachment answers %d", got.StatusCode)
}
// The other note did not inherit it.
_, other := ts.do(t, "GET", "/api/v1/notes?q=Uden", nil)
for _, raw := range other["notes"].([]any) {
if strings.Contains(raw.(map[string]any)["body"].(string), "attachments/") {
t.Error("a note that mentioned no picture was given one")
}
}
}