1package tinyclient23import (4 "context"5 "encoding/json"6 "net/http"7 "net/http/httptest"8 "net/url"9 "strings"10 "testing"1112 "github.com/FelineStateMachine/tinyrelay/internal/policy"13 "github.com/FelineStateMachine/tinyrelay/internal/views"14 "github.com/FelineStateMachine/tinyrelay/internal/wiki"15)1617// viewsBackend reports two custom views and lists them for the owner.18type viewsBackend struct {19 fakeBackend20 views []views.View21 rows []map[string]any22}2324func (b *viewsBackend) CustomViews() []views.View { return b.views }2526func (b *viewsBackend) Query(ctx context.Context, method string, params []json.RawMessage, actor string) (any, error) {27 if method == "listcustomviews" {28 if actor != b.policy.Owner {29 return nil, context.Canceled30 }31 return b.rows, nil32 }33 if method == "browserepo" {34 var request map[string]any35 if len(params) == 1 {36 _ = json.Unmarshal(params[0], &request)37 }38 if request["view"] == "file" {39 return map[string]any{"content": "# Notes\n\n```mermaid\ngraph TD; a-->b;\n```\n\nSee [guide](docs/guide.md).\n"}, nil40 }41 return map[string]any{"identifier": "notes", "entries": []any{}}, nil42 }43 return b.fakeBackend.Query(ctx, method, params, actor)44}4546const mermaidSource = "graph TD;\n A-->B & <C>;"4748func mermaidFigure(view string) string {49 return `<figure data-view="` + view + `"><view-artifact><img src="/views/` + view + `/` + views.Hash("mermaid", mermaidSource) + `" alt="Rendered mermaid block"><details><summary>Source</summary><pre><code data-lang="mermaid">graph TD;` + "\n" + ` A-->B & <C>;</code></pre></details></view-artifact></figure>`50}5152func TestRenderersShowCustomViewArtifactsWithTheCodeAsFallback(t *testing.T) {53 renderer := views.NewRenderer([]views.View{{Name: "diagrams", Languages: []string{"mermaid"}}})54 source := "Intro\n\n```mermaid\n" + mermaidSource + "\n```\n\n```go\nfunc main() {}\n```\n\nhttps://example.com/x\n"55 got := string(renderMarkdownWith(source, renderer.Block))56 wantAll(t, "markdown", got, mermaidFigure("diagrams"), `<pre><code data-lang="go">func main() {}</code></pre>`)57 if strings.Contains(got, "class=") {58 t.Fatal("markdown carries a class attribute")59 }60 // Without a renderer, every fenced block stays code.61 plain := string(renderMarkdown(source))62 wantAll(t, "plain markdown", plain, `<pre><code data-lang="mermaid">graph TD;`)63 wantNone(t, "plain markdown", plain, "<figure", "<object")64 // Chat keeps the figure and links the bare URL outside it.65 chat := string(renderChatMarkdownWith(source, renderer.Block))66 wantAll(t, "chat", chat, mermaidFigure("diagrams"), `<a href="https://example.com/x" rel="noopener">https://example.com/x</a>`)67 // README rendering maps relative links and keeps the figure.68 query := url.Values{"owner": {"alice"}, "repo": {"notes"}, "ref": {"main"}}69 repo := string(renderRepositoryMarkdownWith(source+"[guide](docs/guide.md)\n", query, renderer.Block))70 wantAll(t, "repository", repo, mermaidFigure("diagrams"), `href="/repos/alice/notes/file/docs/guide.md?ref=main"`)71 // The wiki renderer takes the same renderer through its options.72 article := string(wiki.RenderHTMLWith("# Title\n\n``` Mermaid\n"+mermaidSource+"\n```\n\n~~~\nplain\n~~~\n", wiki.Options{Block: renderer.Block}))73 wantAll(t, "wiki", article, "<h1>Title</h1>", mermaidFigure("diagrams"), "<pre><code>plain\n</code></pre>")74 if plainWiki := string(wiki.RenderHTML("```mermaid\n" + mermaidSource + "\n```\n")); strings.Contains(plainWiki, "<figure") || !strings.Contains(plainWiki, `<code data-lang="mermaid">`) {75 t.Fatalf("wiki without options: %s", plainWiki)76 }77}7879func TestPagesRenderCustomViewFiguresFromTheBackendSummary(t *testing.T) {80 owner := strings.Repeat("a", 64)81 backend := &viewsBackend{fakeBackend: fakeBackend{policy: policy.Defaults(owner)}, views: []views.View{{Name: "diagrams", Languages: []string{"mermaid"}}}}82 app, err := New(backend, Options{Actor: func(*http.Request) (string, error) { return owner, nil }})83 if err != nil {84 t.Fatal(err)85 }86 source := "```mermaid\n" + mermaidSource + "\n```"87 for name, got := range map[string]string{88 "markdown": string(app.markdown(source)),89 "chat": string(app.chatMarkdown(source)),90 "wiki": string(app.wikiHTML(source)),91 } {92 wantAll(t, name, got, mermaidFigure("diagrams"))93 }94 // The repository home renders the README through the same renderer,95 // and the tenant prefix reaches the object address.96 recorder := httptest.NewRecorder()97 app.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/repos/"+owner+"/notes", nil))98 body := recorder.Body.String()99 if recorder.Code != http.StatusOK {100 t.Fatalf("repo home: %d %s", recorder.Code, body)101 }102 hash := views.Hash("mermaid", "graph TD; a-->b;")103 figure := `<figure data-view="diagrams"><view-artifact><img src="/views/diagrams/` + hash + `" alt="Rendered mermaid block"><details><summary>Source</summary><pre><code data-lang="mermaid">graph TD; a-->b;</code></pre></details></view-artifact></figure>`104 wantAll(t, "repo home", body, figure, `href="/repos/`)105 if prefixed := injectBase(figure, "/r/work"); !strings.Contains(prefixed, `<img src="/r/work/views/diagrams/`+hash+`"`) {106 t.Fatalf("tenant prefix missed the object address: %s", prefixed)107 }108 // A backend without the summary keeps code blocks as code.109 plainApp, err := New(&fakeBackend{policy: policy.Defaults(owner)}, Options{Actor: func(*http.Request) (string, error) { return owner, nil }})110 if err != nil {111 t.Fatal(err)112 }113 if got := string(plainApp.markdown(source)); strings.Contains(got, "<figure") {114 t.Fatalf("figure without a summary: %s", got)115 }116 backend.views = nil117 if got := string(app.markdown(source)); strings.Contains(got, "<figure") {118 t.Fatalf("figure without views: %s", got)119 }120}121122func TestTemplateClonesKeepBackendSpecificMarkdownHelpers(t *testing.T) {123 owner := strings.Repeat("a", 64)124 withView := &viewsBackend{fakeBackend: fakeBackend{policy: policy.Defaults(owner)}, views: []views.View{{Name: "diagrams", Languages: []string{"mermaid"}}}}125 withView.policy.Description = "```mermaid\ngraph TD; A-->B\n```"126 withoutView := &viewsBackend{fakeBackend: fakeBackend{policy: policy.Defaults(owner)}}127 withoutView.policy.Description = withView.policy.Description128 withApp, err := New(withView, Options{})129 if err != nil {130 t.Fatal(err)131 }132 withoutApp, err := New(withoutView, Options{})133 if err != nil {134 t.Fatal(err)135 }136 withResponse := httptest.NewRecorder()137 withoutResponse := httptest.NewRecorder()138 withApp.ServeHTTP(withResponse, httptest.NewRequest(http.MethodGet, "/", nil))139 withoutApp.ServeHTTP(withoutResponse, httptest.NewRequest(http.MethodGet, "/", nil))140 if !strings.Contains(withResponse.Body.String(), `<figure data-view="diagrams">`) {141 t.Fatal("backend with a custom view did not render its figure")142 }143 if strings.Contains(withoutResponse.Body.String(), `<figure data-view="diagrams">`) {144 t.Fatal("backend without a custom view inherited another App's markdown helper")145 }146}147148func TestViewsPageListsCustomViewsWithControlsAndTheAddForm(t *testing.T) {149 owner := strings.Repeat("a", 64)150 backend := &viewsBackend{fakeBackend: fakeBackend{policy: policy.Defaults(owner)}, rows: []map[string]any{151 {"name": "diagrams", "kinds": []any{1.0, 30023.0}, "languages": []any{"mermaid", "dot"}, "trigger": "write", "audience": "public", "enabled": true, "state": "active", "lastRun": 1700000000.0, "lastStatus": "ok", "transform": "https://render.example/tiny?token=secret-path"},152 {"name": "charts", "kinds": []any{30618.0}, "languages": []any{"vega"}, "trigger": "hourly", "audience": "members", "enabled": false, "state": "paused", "lastRun": 0.0, "lastStatus": "paused after 20 failures: HTTP 500"},153 }}154 app, err := New(backend, Options{Actor: func(r *http.Request) (string, error) { return r.Header.Get("X-Actor"), nil }})155 if err != nil {156 t.Fatal(err)157 }158 get := func(actor string) string {159 t.Helper()160 request := httptest.NewRequest(http.MethodGet, "/manage/views", nil)161 request.Header.Set("X-Actor", actor)162 recorder := httptest.NewRecorder()163 app.ServeHTTP(recorder, request)164 if recorder.Code != http.StatusOK {165 t.Fatalf("views page: %d %s", recorder.Code, recorder.Body.String())166 }167 body := recorder.Body.String()168 if strings.Contains(body, "class=") {169 t.Fatal("views page carries a class attribute")170 }171 if strings.Contains(body, "ยท") {172 t.Fatal("views page contains a middle dot")173 }174 return body175 }176 body := get(owner)177 wantAll(t, "/manage/views", body,178 `<rpc-form method="listviews">`, `<rpc-form method="publishview">`,179 `<table id="custom-views">`, `<tr id="view-diagrams">`, `<code>diagrams</code>`, `<td>1, 30023</td>`, `<td>mermaid, dot</td>`, `<td>write</td>`, `<td>public</td>`,180 `<td data-state="active">active | ok</td>`, `<td>2023-11-14 22:13 UTC</td>`,181 `<rpc-form method="pausecustomview" refresh><input type="hidden" name="param" value="diagrams"><button>Pause</button></rpc-form>`,182 `<rpc-form method="runcustomview" refresh><input type="hidden" name="param" value="diagrams"><button>Run now</button></rpc-form>`,183 `<rpc-form method="removecustomview" refresh><input type="hidden" name="param" value="diagrams"><button>Remove</button></rpc-form>`,184 `<tr id="view-charts">`, `<td data-state="paused">paused | paused after 20 failures: HTTP 500</td>`, `<td>never</td>`,185 `<rpc-form method="resumecustomview" refresh><input type="hidden" name="param" value="charts"><button>Resume</button></rpc-form>`,186 `<view-form method="addcustomview">`, `<input name="view-name" pattern="[a-z0-9-]{1,32}"`, `<input name="view-kinds"`, `<input name="view-languages"`, `<input name="view-transform" type="url"`,187 `<select name="view-trigger"><option value="write">write</option><option value="hourly">hourly</option></select>`, `<select name="view-audience">`, `<input name="view-max-bytes" type="number" min="1" max="4194304"`, `<input name="view-secret" minlength="16" maxlength="128"`, `<button>Add view</button>`)188 wantNone(t, "/manage/views", body, "secret-path", `<rpc-form method="pausecustomview" refresh><input type="hidden" name="param" value="charts">`)189 // A member keeps the built-in controls and sees no custom view section.190 member := get(strings.Repeat("b", 64))191 wantAll(t, "/manage/views as member", member, `<rpc-form method="listviews">`)192 wantNone(t, "/manage/views as member", member, `id="custom-views"`, "<view-form")193 // The owner without views sees the empty note and the form.194 backend.rows = nil195 empty := get(owner)196 wantAll(t, "/manage/views empty", empty, `<p id="custom-views-empty">No custom views yet.</p>`, `<view-form method="addcustomview">`)197 wantNone(t, "/manage/views empty", empty, `<table id="custom-views">`)198}