Files
kandev/apps/backend/internal/task/share/gist_backend_test.go
T
João Salavisa 07a9784d1c fix(share): route share links through gist.githack so big tasks render (#1008)
* fix(share): route share links through gist.githack so big tasks render

gistpreview.github.io fetches gists via the public GitHub API, which returns
`content: ""` for files past GitHub's per-response content budget (~1 MB).
For non-trivial sessions, share.html and snapshot.json land past that
budget and gistpreview's `document.write(content)` renders a blank page.
gist.githack.com proxies the raw file content directly, so multi-MB
share.html renders correctly — at the cost of a one-time Cloudflare
anti-phishing interstitial on first visit, accepted as the price of
fidelity for big tasks.

displayURL now normalises bare gist URLs and unpinned githack URLs to the
canonical `gist.githack.com/<owner>/<id>/raw/share.html` form; legacy
gistpreview URLs pass through unchanged (the owner is not recoverable
from a gistpreview URL, so users can revoke + re-share to get the
githack form going forward).

* fix(share): address PR #1008 review feedback

- displayURL now re-pins legacy gistpreview URLs stored without /share.html
  so they hit the styled view instead of silently falling back to the
  alphabetically-first README.md (flagged by Greptile).
- Spec: unify two stale gistpreview references (API endpoint description
  and the publish scenario) to the canonical githack form, with a note
  that legacy gistpreview rows pass through. Flagged by CodeRabbit and
  Claude — the spec is the public contract; one stale mention undermines
  the migration.

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
2026-05-22 11:40:36 +01:00

132 lines
3.8 KiB
Go

package share
import (
"context"
"errors"
"strings"
"testing"
"time"
"github.com/kandev/kandev/internal/github"
)
func sampleSnapshot() *Snapshot {
completed := time.Date(2026, 5, 21, 12, 0, 0, 0, time.UTC)
return &Snapshot{
Version: SnapshotVersion,
ExportedAt: completed,
Task: TaskMeta{Title: "Fix flaky test"},
Session: SessionMeta{
AgentType: "claude-acp",
Model: "claude-opus-4-7",
ExecutorType: "local_docker",
StartedAt: completed.Add(-time.Minute),
CompletedAt: &completed,
},
Messages: []Message{
{Role: "user", Ts: completed, Blocks: []Block{{Kind: "text", Text: "hi"}}},
},
Redaction: RedactionLog{AppliedRules: []string{}},
}
}
func TestGistBackend_Upload_CreatesSecretGistWithExpectedFiles(t *testing.T) {
t.Parallel()
mock := github.NewMockClient()
b := NewGistBackend(mock)
id, url, err := b.Upload(context.Background(), sampleSnapshot())
if err != nil {
t.Fatalf("upload: %v", err)
}
if id == "" || url == "" {
t.Fatalf("expected non-empty id/url, got %q / %q", id, url)
}
// The returned URL is the gist.githack.com raw-render URL, NOT the
// raw gist URL — githack proxies the full file content so big shares
// render even when GitHub's gists API would have returned an empty
// content field.
if !strings.HasPrefix(url, "https://gist.githack.com/") ||
!strings.HasSuffix(url, "/raw/share.html") {
t.Fatalf("expected gist.githack.com /raw/share.html URL, got %q", url)
}
gists := mock.Gists()
g, ok := gists[id]
if !ok {
t.Fatalf("gist %s not stored on mock", id)
}
if g.Public {
t.Fatal("backend should always create secret gists (Public=false)")
}
for _, name := range []string{"snapshot.json", "README.md", "share.html"} {
if _, ok := g.Files[name]; !ok {
t.Fatalf("missing %s in gist files", name)
}
}
if !strings.Contains(g.Files["snapshot.json"].Content, `"title": "Fix flaky test"`) {
t.Fatalf("snapshot.json does not contain task title: %s", g.Files["snapshot.json"].Content)
}
if !strings.Contains(g.Files["README.md"].Content, "# Fix flaky test") {
t.Fatal("README.md missing task title heading")
}
if !strings.Contains(g.Files["share.html"].Content, "<!doctype html>") {
t.Fatal("share.html missing doctype")
}
if !strings.Contains(g.Files["share.html"].Content, "Fix flaky test") {
t.Fatal("share.html missing task title")
}
}
func TestGistBackend_Upload_RejectsOversizedSnapshot(t *testing.T) {
t.Parallel()
mock := github.NewMockClient()
b := NewGistBackend(mock)
snap := sampleSnapshot()
big := strings.Repeat("x", GistMaxBytes+1)
snap.Messages = []Message{{Role: "user", Blocks: []Block{{Kind: "text", Text: big}}}}
_, _, err := b.Upload(context.Background(), snap)
if err == nil {
t.Fatal("expected ErrSnapshotTooLarge, got nil")
}
if !errors.Is(err, ErrSnapshotTooLarge) {
t.Fatalf("expected ErrSnapshotTooLarge, got %v", err)
}
if len(mock.Gists()) != 0 {
t.Fatalf("no gist should have been created, got %d", len(mock.Gists()))
}
}
func TestGistBackend_Delete_Success(t *testing.T) {
t.Parallel()
mock := github.NewMockClient()
b := NewGistBackend(mock)
id, _, err := b.Upload(context.Background(), sampleSnapshot())
if err != nil {
t.Fatalf("upload: %v", err)
}
if err := b.Delete(context.Background(), id); err != nil {
t.Fatalf("delete: %v", err)
}
if got := mock.DeletedGists(); len(got) != 1 || got[0] != id {
t.Fatalf("expected DeletedGists=[%q], got %v", id, got)
}
}
func TestGistBackend_Delete_PassesThroughNotFoundForCallerInspection(t *testing.T) {
t.Parallel()
mock := github.NewMockClient()
b := NewGistBackend(mock)
err := b.Delete(context.Background(), "missing-id")
if err == nil {
t.Fatal("expected error for missing gist")
}
if !IsAlreadyGone(err) {
t.Fatalf("expected IsAlreadyGone to be true, got err=%v", err)
}
}