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;`, `