package docs import ( "archive/zip" "bytes" "encoding/json" "io" "net/http" "strings" "testing" ) // richDocJSON is a Tiptap document exercising headings, marks, and a list — // including CJK text, which every format must carry through intact. const richDocJSON = `{"type":"doc","content":[` + `{"type":"heading","attrs":{"level":2},"content":[{"type":"text","text":"My Section"}]},` + `{"type":"paragraph","content":[{"type":"text","text":"Hello "},{"type":"text","marks":[{"type":"bold"}],"text":"world"},{"type":"text","text":" 你好"}]},` + `{"type":"bulletList","content":[` + `{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"first"}]}]},` + `{"type":"listItem","content":[{"type":"paragraph","content":[{"type":"text","text":"second"}]}]}` + `]}` + `]}` func seedRichDoc(t *testing.T, srv http.Handler) string { t.Helper() id := newDoc(t, srv) body := `{"title":"日记 Diary","content":` + jsonString(richDocJSON) + `,"content_text":"My Section\nHello world 你好\nfirst\nsecond","word_count":6}` if rec := do(t, srv, http.MethodPut, "/"+id, body); rec.Code != http.StatusOK { t.Fatalf("seed rich doc: code=%d body=%s", rec.Code, rec.Body) } return id } // jsonString quotes a string as a JSON string literal (so the Tiptap JSON can be // embedded as the "content" field value). func jsonString(s string) string { b, _ := json.Marshal(s) return string(b) } func TestExportMarkdown(t *testing.T) { srv := newTestServer(t) id := seedRichDoc(t, srv) rec := do(t, srv, http.MethodGet, "/"+id+"/export?format=md", "") if rec.Code != http.StatusOK { t.Fatalf("export md: code=%d body=%s", rec.Code, rec.Body) } if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/markdown") { t.Fatalf("unexpected content-type: %q", ct) } out := rec.Body.String() for _, want := range []string{"## My Section", "**world**", "你好", "- first", "- second"} { if !strings.Contains(out, want) { t.Fatalf("markdown missing %q in:\n%s", want, out) } } // CJK filename must survive in the RFC 5987 form. if cd := rec.Header().Get("Content-Disposition"); !strings.Contains(cd, "filename*=UTF-8''") { t.Fatalf("expected RFC 5987 filename, got %q", cd) } } func TestExportAll(t *testing.T) { srv := newTestServer(t) // Two docs, one with a duplicate title to exercise filename de-duplication. seedRichDoc(t, srv) id2 := newDoc(t, srv) if rec := do(t, srv, http.MethodPut, "/"+id2, `{"title":"日记 Diary","content":`+jsonString(richDocJSON)+`,"content_text":"x","word_count":1}`); rec.Code != http.StatusOK { t.Fatalf("seed second doc: %d %s", rec.Code, rec.Body) } rec := do(t, srv, http.MethodGet, "/export-all?format=md", "") if rec.Code != http.StatusOK { t.Fatalf("export-all: code=%d body=%s", rec.Code, rec.Body) } if ct := rec.Header().Get("Content-Type"); ct != "application/zip" { t.Fatalf("unexpected content-type: %q", ct) } zr, err := zip.NewReader(bytes.NewReader(rec.Body.Bytes()), int64(rec.Body.Len())) if err != nil { t.Fatalf("zip open: %v", err) } if len(zr.File) != 2 { t.Fatalf("expected 2 files in backup, got %d", len(zr.File)) } names := map[string]bool{} for _, f := range zr.File { names[f.Name] = true rc, _ := f.Open() b, _ := io.ReadAll(rc) rc.Close() if !strings.Contains(string(b), "你好") { t.Fatalf("backup entry %q missing CJK body", f.Name) } } // The duplicate title must have been disambiguated, not overwritten. if !names["日记 Diary.md"] || !names["日记 Diary (1).md"] { t.Fatalf("expected de-duplicated filenames, got %v", names) } } func TestExportHTML(t *testing.T) { srv := newTestServer(t) id := seedRichDoc(t, srv) rec := do(t, srv, http.MethodGet, "/"+id+"/export?format=html", "") out := rec.Body.String() for _, want := range []string{"", "

My Section

", "world", "你好", "
  • first
  • "} { if !strings.Contains(out, want) { t.Fatalf("html missing %q", want) } } } func TestExportPlainText(t *testing.T) { srv := newTestServer(t) id := seedRichDoc(t, srv) rec := do(t, srv, http.MethodGet, "/"+id+"/export?format=txt", "") out := rec.Body.String() for _, want := range []string{"My Section", "Hello world 你好", "• first"} { if !strings.Contains(out, want) { t.Fatalf("txt missing %q in:\n%s", want, out) } } } func TestExportDocx(t *testing.T) { srv := newTestServer(t) id := seedRichDoc(t, srv) rec := do(t, srv, http.MethodGet, "/"+id+"/export?format=docx", "") if rec.Code != http.StatusOK { t.Fatalf("export docx: code=%d", rec.Code) } raw := rec.Body.Bytes() zr, err := zip.NewReader(bytes.NewReader(raw), int64(len(raw))) if err != nil { t.Fatalf("docx is not a valid zip: %v", err) } want := map[string]bool{"[Content_Types].xml": false, "word/document.xml": false} var docXML string for _, f := range zr.File { if _, ok := want[f.Name]; ok { want[f.Name] = true } if f.Name == "word/document.xml" { rc, _ := f.Open() b, _ := io.ReadAll(rc) rc.Close() docXML = string(b) } } for name, found := range want { if !found { t.Fatalf("docx missing part %q", name) } } for _, w := range []string{"My Section", "你好", "", "Heading2"} { if !strings.Contains(docXML, w) { t.Fatalf("document.xml missing %q", w) } } } // richDocJSON2 exercises the newer node/mark types: links, highlight, an image, // and a table (with a header row). Each export format should carry them through. const richDocJSON2 = `{"type":"doc","content":[` + `{"type":"paragraph","content":[` + `{"type":"text","marks":[{"type":"link","attrs":{"href":"https://petal.test"}}],"text":"site"},` + `{"type":"text","text":" and "},` + `{"type":"text","marks":[{"type":"highlight","attrs":{"color":"#FFF1A8"}}],"text":"lit"}` + `]},` + `{"type":"image","attrs":{"src":"/api/images/abc123.png","alt":"a cat"}},` + `{"type":"table","content":[` + `{"type":"tableRow","content":[` + `{"type":"tableHeader","content":[{"type":"paragraph","content":[{"type":"text","text":"Name"}]}]},` + `{"type":"tableHeader","content":[{"type":"paragraph","content":[{"type":"text","text":"年龄"}]}]}` + `]},` + `{"type":"tableRow","content":[` + `{"type":"tableCell","content":[{"type":"paragraph","content":[{"type":"text","text":"Mei"}]}]},` + `{"type":"tableCell","content":[{"type":"paragraph","content":[{"type":"text","text":"30"}]}]}` + `]}` + `]}` + `]}` func seedRichDoc2(t *testing.T, srv http.Handler) string { t.Helper() id := newDoc(t, srv) body := `{"title":"Rich2","content":` + jsonString(richDocJSON2) + `,"content_text":"site and lit\nName 年龄 Mei 30","word_count":6}` if rec := do(t, srv, http.MethodPut, "/"+id, body); rec.Code != http.StatusOK { t.Fatalf("seed rich doc 2: code=%d body=%s", rec.Code, rec.Body) } return id } func TestExportLinksImagesTables(t *testing.T) { srv := newTestServer(t) id := seedRichDoc2(t, srv) md := do(t, srv, http.MethodGet, "/"+id+"/export?format=md", "").Body.String() for _, want := range []string{"[site](https://petal.test)", "![a cat](/api/images/abc123.png)", "| Name | 年龄 |", "| --- | --- |", "| Mei | 30 |"} { if !strings.Contains(md, want) { t.Fatalf("markdown missing %q in:\n%s", want, md) } } html := do(t, srv, http.MethodGet, "/"+id+"/export?format=html", "").Body.String() for _, want := range []string{`site`, "lit", `a cat`, "Name", "Mei"} { if !strings.Contains(html, want) { t.Fatalf("html missing %q in:\n%s", want, html) } } docx := do(t, srv, http.MethodGet, "/"+id+"/export?format=docx", "") raw := docx.Body.Bytes() zr, err := zip.NewReader(bytes.NewReader(raw), int64(len(raw))) if err != nil { t.Fatalf("docx not a zip: %v", err) } var docXML string for _, f := range zr.File { if f.Name == "word/document.xml" { rc, _ := f.Open() b, _ := io.ReadAll(rc) rc.Close() docXML = string(b) } } for _, want := range []string{"", "Name", "年龄", "[a cat]", "