package main import ( "encoding/json" "net/http" "net/http/httptest" "net/url" "os" "path/filepath" "strings" "testing" ) func writeTestFile(t *testing.T, path, content string) { t.Helper() if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { t.Fatal(err) } if err := os.WriteFile(path, []byte(content), 0644); err != nil { t.Fatal(err) } } func canvasRequest(h *handler, method, target, body string, header map[string]string) *httptest.ResponseRecorder { r := httptest.NewRequest(method, target, strings.NewReader(body)) for k, v := range header { r.Header.Set(k, v) } w := httptest.NewRecorder() h.ServeHTTP(w, r) return w } func TestParseCanvas(t *testing.T) { ok := []string{ ``, `{}`, `{"nodes":[{"id":"a","type":"text","x":0,"y":0,"width":10,"height":10,"text":"hi","color":"3"}, {"id":"b","type":"group","x":0,"y":0,"width":10,"height":10,"color":"#ff8800"}], "edges":[{"id":"e","fromNode":"a","toNode":"b","fromSide":"left","toEnd":"none"}]}`, } for _, src := range ok { if _, err := parseCanvas([]byte(src)); err != nil { t.Errorf("parseCanvas(%s): %v", src, err) } } bad := map[string]string{ "not an object": `[]`, "syntax": `{"nodes":[`, "no id": `{"nodes":[{"type":"text","x":0,"y":0,"width":1,"height":1}]}`, "duplicate id": `{"nodes":[{"id":"a","type":"text"},{"id":"a","type":"text"}]}`, "unknown type": `{"nodes":[{"id":"a","type":"blob"}]}`, "file w/o path": `{"nodes":[{"id":"a","type":"file"}]}`, "link w/o url": `{"nodes":[{"id":"a","type":"link"}]}`, "bad color": `{"nodes":[{"id":"a","type":"text","color":"red;x"}]}`, "string x": `{"nodes":[{"id":"a","type":"text","x":"1"}]}`, "dangling edge": `{"nodes":[{"id":"a","type":"text"}],"edges":[{"id":"e","fromNode":"a","toNode":"zz"}]}`, "bad side": `{"nodes":[{"id":"a","type":"text"}],"edges":[{"id":"e","fromNode":"a","toNode":"a","toSide":"up"}]}`, "bad end": `{"nodes":[{"id":"a","type":"text"}],"edges":[{"id":"e","fromNode":"a","toNode":"a","toEnd":"dot"}]}`, } for name, src := range bad { if _, err := parseCanvas([]byte(src)); err == nil { t.Errorf("%s: parseCanvas accepted %s", name, src) } } } func TestCanvasSave(t *testing.T) { initMarkdown() root := t.TempDir() file := filepath.Join(root, "Page", "Board.canvas") writeTestFile(t, file, string(emptyCanvas)) h := &handler{root: root} save := func(body, version string) *httptest.ResponseRecorder { return canvasRequest(h, "POST", "/Page/Board.canvas?save", body, map[string]string{"X-Canvas-Version": version}) } // Fields the server does not know survive the round trip. doc := `{"nodes":[{"id":"a","type":"text","x":1,"y":2,"width":3,"height":4,"text":"hi","custom":{"k":1}}],"edges":[],"extra":true}` w := save(doc, canvasVersion(emptyCanvas)) if w.Code != http.StatusNoContent { t.Fatalf("save: %d %s", w.Code, w.Body) } saved, _ := os.ReadFile(file) var got map[string]any if err := json.Unmarshal(saved, &got); err != nil { t.Fatal(err) } if got["extra"] != true || !strings.Contains(string(saved), `"custom"`) { t.Errorf("unknown fields lost:\n%s", saved) } if v := w.Header().Get("X-Canvas-Version"); v != canvasVersion(saved) { t.Errorf("returned version %q does not match the file", v) } // A save based on an older version is refused and changes nothing. if w := save(`{"nodes":[],"edges":[]}`, canvasVersion(emptyCanvas)); w.Code != http.StatusConflict { t.Errorf("stale save: got %d, want 409", w.Code) } if now, _ := os.ReadFile(file); string(now) != string(saved) { t.Error("stale save changed the file") } // Invalid documents are refused. if w := save(`{"nodes":[{"id":"a","type":"nope"}]}`, canvasVersion(saved)); w.Code != http.StatusBadRequest { t.Errorf("invalid save: got %d, want 400", w.Code) } if w := save(``, canvasVersion(saved)); w.Code != http.StatusBadRequest { t.Errorf("empty save: got %d, want 400", w.Code) } // A canvas deleted while open is not recreated as a page. os.Remove(file) if w := save(doc, canvasVersion(saved)); w.Code != http.StatusNotFound { t.Errorf("save to deleted canvas: got %d, want 404", w.Code) } if _, err := os.Stat(filepath.Join(root, "Page", "Board.canvas")); !os.IsNotExist(err) { t.Error("save to deleted canvas created something") } } func TestCanvasPage(t *testing.T) { initMarkdown() root := t.TempDir() writeTestFile(t, filepath.Join(root, "Page", "Board.canvas"), `{"nodes":[{"id":"n1","type":"text","x":10,"y":20,"width":200,"height":100,"text":"# Hello\n\n- [ ] task","color":"2"}],"edges":[]}`) writeTestFile(t, filepath.Join(root, "Page", "Broken.canvas"), `{"nodes":[`) h := &handler{root: root} w := canvasRequest(h, "GET", "/Page/Board.canvas", "", nil) body := w.Body.String() for _, want := range []string{ `class="canvas-app"`, `data-id="n1"`, `data-color="2"`, `left: 10px; top: 20px; width: 200px; height: 100px;`, `

Hello

`, `class="task-checkbox" data-task="0"`, `id="canvas-data"`, } { if !strings.Contains(body, want) { t.Errorf("canvas page lacks %q", want) } } for _, unwanted := range []string{`tree-sidebar`, `hx-boost="true"`, `)\n\n## Part\n\nPart text\n\n## Rest\n\nRest text\n") writeTestFile(t, filepath.Join(root, "Other Page", "notes.md"), "[rel](sub/x.md) [abs](/y/) [web](https://e.x)\n") writeTestFile(t, filepath.Join(root, "Other Page", "pic one.jpg"), "not really") writeTestFile(t, filepath.Join(root, "Other Page", "data.zip"), "zip") writeTestFile(t, filepath.Join(root, ".secret", "x.md"), "secret") writeTestFile(t, filepath.Join(root, "Board.canvas"), "") h := &handler{root: root} render := func(node string) string { w := canvasRequest(h, "POST", "/Board.canvas?render", node, nil) if w.Code != 200 { t.Fatalf("render %s: %d %s", node, w.Code, w.Body) } return w.Body.String() } page := render(`{"id":"a","type":"file","file":"Other Page","x":0,"y":0,"width":1,"height":1}`) if !strings.Contains(page, `href="/Other%20Page/"`) || !strings.Contains(page, ">Other<") || !strings.Contains(page, `src="/Other%20Page/pic%20one.jpg?w=300"`) { t.Errorf("page node:\n%s", page) } part := render(`{"id":"a","type":"file","file":"/Other Page/index.md","subpath":"#Part","x":0,"y":0,"width":1,"height":1}`) if !strings.Contains(part, "Part text") || strings.Contains(part, "Rest text") || strings.Contains(part, "shot") { t.Errorf("subpath node:\n%s", part) } notes := render(`{"id":"a","type":"file","file":"Other Page/notes.md","x":0,"y":0,"width":1,"height":1}`) for _, want := range []string{`href="/Other%20Page/sub/x.md"`, `href="/y/"`, `href="https://e.x"`} { if !strings.Contains(notes, want) { t.Errorf("markdown file node lacks %s:\n%s", want, notes) } } if img := render(`{"id":"a","type":"file","file":"Other Page/pic one.jpg","x":0,"y":0,"width":1,"height":1}`); !strings.Contains(img, `G","color":"#abc","x":0,"y":0,"width":1,"height":1}`) if !strings.Contains(group, "<b>G</b>") || !strings.Contains(group, "--node-color: #abc") { t.Errorf("group node:\n%s", group) } } func TestCanvasCreate(t *testing.T) { root := t.TempDir() h := &handler{root: root} create := func(name string) *httptest.ResponseRecorder { form := url.Values{"name": {name}}.Encode() return canvasRequest(h, "POST", "/New%20Page/?canvas", form, map[string]string{ "Content-Type": "application/x-www-form-urlencoded", "HX-Request": "true", }) } w := create("My Board") if w.Code != http.StatusNoContent || w.Header().Get("HX-Redirect") != "/New%20Page/My%20Board.canvas" { t.Fatalf("create: %d %q %s", w.Code, w.Header().Get("HX-Redirect"), w.Body) } raw, err := os.ReadFile(filepath.Join(root, "New Page", "My Board.canvas")) if err != nil || string(raw) != string(emptyCanvas) { t.Fatalf("created file: %v %q", err, raw) } if w := create("My Board.canvas"); w.Code != http.StatusConflict { t.Errorf("duplicate: got %d, want 409", w.Code) } for _, bad := range []string{"", " ", ".hidden", "a/b", `a\b`} { if w := create(bad); w.Code != http.StatusBadRequest { t.Errorf("name %q: got %d, want 400", bad, w.Code) } } } func TestCanvasListedWithIcon(t *testing.T) { if fileIcon("Board.canvas") != iconCanvas { t.Error("canvas files do not get the canvas icon") } }