Files
rose-ash/lib/gitea/tests/issues.sx
giles d96529effe sx-gitea Phase 4: issues — content-document bodies + relations graph (TDD, 360/360)
lib/gitea/issues.sx: issues as kv records (zero-padded per-repo
numbering, title/author/state, sorted label+assignee sets, Markdown
body, comment thread). Bodies and comments are content-on-sx documents:
content/from-markdown -> block doc -> content/html for pages, with the
round-trip law asserted in the suite. The issue graph (issue->repo
parent, author origin, assignee member, label link, commenter reply) is
DERIVED into lib/relations facts and rebuilt on fact change — same
pattern as the acl db, so deleting a repo can never dangle edges.

Views: open/closed/by-label/by-assignee; graph queries: repo-issue-nodes,
user-authored, user-assigned, label-issues, issue-participants.

Web: issues list + issue page (rendered HTML body + comments), JSON API:
create (any authenticated reader), comment, close/reopen (author or
write), label/assignee management (write). All read-gated like the rest.

Infra: gitea/route-packs registry — wire/issues append their routes at
load; gitea/app serves all packs. repo-delete! now purges collab/issue/
issue-seq rows too (ghost-state regression tested). Conformance runner
gains per-suite extra modules; the issues suite loads relations +
smalltalk + content (~5s).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 13:53:21 +00:00

572 lines
16 KiB
Plaintext

; lib/gitea/tests/issues.sx — Phase 4: issue CRUD, comments, labels,
; assignees, content-document bodies (Markdown round-trip + HTML render),
; the derived relations graph, repo-delete purge regression, and the
; issue web routes + JSON API.
(st-bootstrap-classes!)
(content/bootstrap!)
(content-bootstrap-markdown!)
(content-bootstrap-table!)
(define gitea-issues-pass 0)
(define gitea-issues-fail 0)
(define gitea-issues-fails (list))
(define
gitea-issues-test
(fn
(name actual expected)
(if
(= actual expected)
(set! gitea-issues-pass (+ gitea-issues-pass 1))
(begin
(set! gitea-issues-fail (+ gitea-issues-fail 1))
(set!
gitea-issues-fails
(append gitea-issues-fails (list {:name name :expected (inspect expected) :actual (inspect actual)})))))))
; ── helpers ──────────────────────────────────────────────────────────
(gitea-issues-test "pad8" (gitea/pad8 7) "00000007")
(gitea-issues-test "pad8 wide" (gitea/pad8 12345) "00012345")
(gitea-issues-test "digits? yes" (gitea/digits? "123") true)
(gitea-issues-test "digits? no" (gitea/digits? "12a") false)
(gitea-issues-test "digits? empty" (gitea/digits? "") false)
; ── setup ────────────────────────────────────────────────────────────
(define gi-db (persist/mem-backend))
(define gi-forge (gitea/forge gi-db))
(gitea/user-create! gi-forge "alice")
(gitea/user-create! gi-forge "bob")
(gitea/user-create! gi-forge "carol")
(gitea/user-create! gi-forge "eve")
(gitea/repo-create! gi-forge "alice" "proj" {})
(gitea/repo-create! gi-forge "alice" "sec" {:visibility "private"})
(gitea/collab-add! gi-forge "alice" "sec" "bob" "read")
(gitea/token-create! gi-forge "alice" "tok-a")
(gitea/token-create! gi-forge "bob" "tok-b")
(gitea/token-create! gi-forge "eve" "tok-e")
; ── issue CRUD ───────────────────────────────────────────────────────
(define
gi-i1
(gitea/issue-create!
gi-forge
"alice"
"proj"
"alice"
"Crash on boot"
"It crashes."
{:created-at 10}))
(gitea-issues-test "create number" (get gi-i1 :number) 1)
(gitea-issues-test "create state" (get gi-i1 :state) "open")
(gitea-issues-test "create title" (get gi-i1 :title) "Crash on boot")
(gitea-issues-test "create author" (get gi-i1 :author) "alice")
(gitea-issues-test "create created-at" (get gi-i1 :created-at) 10)
(define
gi-i2
(gitea/issue-create!
gi-forge
"alice"
"proj"
"bob"
"Add docs"
"Docs please."
{}))
(gitea-issues-test "second number" (get gi-i2 :number) 2)
(gitea-issues-test
"issue-get"
(get (gitea/issue-get gi-forge "alice" "proj" 1) :title)
"Crash on boot")
(gitea-issues-test
"issues list"
(gitea/issues gi-forge "alice" "proj")
(list 1 2))
(gitea-issues-test
"issue-records len"
(len (gitea/issue-records gi-forge "alice" "proj"))
2)
(gitea-issues-test
"create on missing repo"
(get
(gitea/issue-create! gi-forge "alice" "none" "alice" "t" "" {})
:error)
"no-such-repo")
(gitea-issues-test
"create by missing user"
(get
(gitea/issue-create! gi-forge "alice" "proj" "zeb" "t" "" {})
:error)
"no-such-user")
(gitea-issues-test
"create empty title"
(get
(gitea/issue-create! gi-forge "alice" "proj" "alice" "" "" {})
:error)
"empty-title")
(gitea/issue-close! gi-forge "alice" "proj" 2)
(gitea-issues-test
"close!"
(get (gitea/issue-get gi-forge "alice" "proj" 2) :state)
"closed")
(gitea/issue-reopen! gi-forge "alice" "proj" 2)
(gitea-issues-test
"reopen!"
(get (gitea/issue-get gi-forge "alice" "proj" 2) :state)
"open")
(gitea-issues-test
"close missing"
(gitea/issue-close! gi-forge "alice" "proj" 99)
nil)
(gitea/issue-close! gi-forge "alice" "proj" 2)
; ── comments ─────────────────────────────────────────────────────────
(gitea-issues-test
"comment author"
(get
(gitea/issue-comment!
gi-forge
"alice"
"proj"
1
"bob"
"Repro *here*."
{:at 11})
:author)
"bob")
(gitea/issue-comment!
gi-forge
"alice"
"proj"
1
"carol"
"Same for me."
{:at 12})
(gitea-issues-test
"comments appended"
(len (get (gitea/issue-get gi-forge "alice" "proj" 1) :comments))
2)
(gitea-issues-test
"comment order"
(get
(first
(get (gitea/issue-get gi-forge "alice" "proj" 1) :comments))
:body)
"Repro *here*.")
(gitea-issues-test
"comment on missing issue"
(get
(gitea/issue-comment!
gi-forge
"alice"
"proj"
99
"bob"
"x"
{})
:error)
"no-such-issue")
(gitea-issues-test
"comment by missing user"
(get
(gitea/issue-comment!
gi-forge
"alice"
"proj"
1
"zeb"
"x"
{})
:error)
"no-such-user")
; ── labels / assignees ───────────────────────────────────────────────
(gitea/issue-label! gi-forge "alice" "proj" 1 "ui")
(gitea/issue-label! gi-forge "alice" "proj" 1 "bug")
(gitea-issues-test
"labels sorted"
(get (gitea/issue-get gi-forge "alice" "proj" 1) :labels)
(list "bug" "ui"))
(gitea/issue-label! gi-forge "alice" "proj" 1 "bug")
(gitea-issues-test
"label idempotent"
(get (gitea/issue-get gi-forge "alice" "proj" 1) :labels)
(list "bug" "ui"))
(gitea/issue-unlabel! gi-forge "alice" "proj" 1 "bug")
(gitea-issues-test
"unlabel"
(get (gitea/issue-get gi-forge "alice" "proj" 1) :labels)
(list "ui"))
(gitea-issues-test
"invalid label"
(get (gitea/issue-label! gi-forge "alice" "proj" 1 "") :error)
"invalid-label")
(gitea/issue-assign! gi-forge "alice" "proj" 2 "carol")
(gitea-issues-test
"assign"
(get (gitea/issue-get gi-forge "alice" "proj" 2) :assignees)
(list "carol"))
(gitea-issues-test
"assign unknown user"
(get (gitea/issue-assign! gi-forge "alice" "proj" 2 "zeb") :error)
"no-such-user")
; ── views ────────────────────────────────────────────────────────────
(gitea-issues-test
"issues-open"
(len (gitea/issues-open gi-forge "alice" "proj"))
1)
(gitea-issues-test
"issues-closed"
(len (gitea/issues-closed gi-forge "alice" "proj"))
1)
(gitea-issues-test
"issues-with-label"
(map
(fn (r) (get r :number))
(gitea/issues-with-label gi-forge "alice" "proj" "ui"))
(list 1))
(gitea-issues-test
"issues-assigned"
(map
(fn (r) (get r :number))
(gitea/issues-assigned gi-forge "alice" "proj" "carol"))
(list 2))
; ── content documents ────────────────────────────────────────────────
(define gi-md "# Heading\n\npara text.\n\n```sx\n(+ 1 2)\n```")
(define
gi-i3
(gitea/issue-create!
gi-forge
"alice"
"proj"
"alice"
"With md body"
gi-md
{}))
(define gi-doc (gitea/issue-doc "alice" "proj" gi-i3))
(gitea-issues-test "issue doc block count" (content/count gi-doc) 3)
(gitea-issues-test
"issue doc types"
(content/types gi-doc)
(list "heading" "text" "code"))
(gitea-issues-test
"issue html heading"
(contains? (gitea/issue-html "alice" "proj" gi-i3) "<h1>Heading</h1>")
true)
(gitea-issues-test
"issue html code block"
(contains? (gitea/issue-html "alice" "proj" gi-i3) "<pre><code")
true)
(gitea-issues-test "markdown round trip" (content/markdown gi-doc) gi-md)
(gitea-issues-test
"comment md renders"
(contains? (gitea/md-html "Repro *here*." "t1") "<p>")
true)
; ── relations graph ──────────────────────────────────────────────────
(gitea-issues-test
"repo issue nodes"
(gitea/repo-issue-nodes gi-forge "alice" "proj")
(list "issue:alice/proj#1" "issue:alice/proj#2" "issue:alice/proj#3"))
(gitea-issues-test
"authored by alice"
(gitea/user-authored gi-forge "alice")
(list "issue:alice/proj#1" "issue:alice/proj#3"))
(gitea-issues-test
"authored by bob"
(gitea/user-authored gi-forge "bob")
(list "issue:alice/proj#2"))
(gitea-issues-test
"assigned to carol"
(gitea/user-assigned gi-forge "carol")
(list "issue:alice/proj#2"))
(gitea-issues-test
"label issues"
(gitea/label-issues gi-forge "alice" "proj" "ui")
(list "issue:alice/proj#1"))
(gitea-issues-test
"participants incl commenters"
(gitea/issue-participants gi-forge "alice" "proj" 1)
(list "user:alice" "user:bob" "user:carol"))
(gitea-issues-test
"participants author+assignee"
(gitea/issue-participants gi-forge "alice" "proj" 2)
(list "user:bob" "user:carol"))
; ── repo delete purges issue state ───────────────────────────────────
(gitea/repo-create! gi-forge "alice" "tmp" {})
(gitea/issue-create! gi-forge "alice" "tmp" "alice" "Ghost?" "" {})
(gitea/collab-add! gi-forge "alice" "tmp" "carol" "write")
(gitea/repo-delete! gi-forge "alice" "tmp")
(gitea/repo-create! gi-forge "alice" "tmp" {})
(gitea-issues-test
"recreated repo has no ghost issues"
(gitea/issues gi-forge "alice" "tmp")
(list))
(gitea-issues-test
"recreated repo has no ghost collabs"
(gitea/collabs gi-forge "alice" "tmp")
(list))
(gitea-issues-test
"issue numbering restarts"
(get
(gitea/issue-create! gi-forge "alice" "tmp" "alice" "Fresh" "" {})
:number)
1)
(gitea/repo-delete! gi-forge "alice" "tmp")
(gitea-issues-test
"deleted repo leaves no issue edges"
(gitea/repo-issue-nodes gi-forge "alice" "tmp")
(list))
; ── web routes ───────────────────────────────────────────────────────
(define gi-app (gitea/app gi-forge))
(define gi-hdr (fn (tok) (if (nil? tok) {} {:authorization (str "Bearer " tok)})))
(define
gi-get
(fn (target tok) (gi-app (dream-request "GET" target (gi-hdr tok) ""))))
(define
gi-post
(fn
(target tok body)
(gi-app (dream-request "POST" target (gi-hdr tok) body))))
(define
gi-put
(fn
(target tok body)
(gi-app (dream-request "PUT" target (gi-hdr tok) body))))
(define
gi-del
(fn
(target tok)
(gi-app (dream-request "DELETE" target (gi-hdr tok) ""))))
(gitea-issues-test
"issues page 200"
(dream-status (gi-get "/alice/proj/issues" nil))
200)
(gitea-issues-test
"issues page lists title"
(contains?
(dream-resp-body (gi-get "/alice/proj/issues" nil))
"Crash on boot")
true)
(gitea-issues-test
"issues page shows state"
(contains? (dream-resp-body (gi-get "/alice/proj/issues" nil)) "[closed]")
true)
(gitea-issues-test
"issue page 200"
(dream-status (gi-get "/alice/proj/issues/1" nil))
200)
(gitea-issues-test
"issue page shows author"
(contains? (dream-resp-body (gi-get "/alice/proj/issues/1" nil)) "alice")
true)
(gitea-issues-test
"issue page renders body html"
(contains?
(dream-resp-body (gi-get "/alice/proj/issues/3" nil))
"<h1>Heading</h1>")
true)
(gitea-issues-test
"issue page renders comments"
(contains?
(dream-resp-body (gi-get "/alice/proj/issues/1" nil))
"Same for me.")
true)
(gitea-issues-test
"issue page bad number 404"
(dream-status (gi-get "/alice/proj/issues/abc" nil))
404)
(gitea-issues-test
"issue page missing 404"
(dream-status (gi-get "/alice/proj/issues/99" nil))
404)
(gitea-issues-test
"private issues anon 404"
(dream-status (gi-get "/alice/sec/issues" nil))
404)
(gitea-issues-test
"private issues collab 200"
(dream-status (gi-get "/alice/sec/issues" "tok-b"))
200)
(gitea-issues-test
"api issues len"
(len
(dream-json-parse
(dream-resp-body (gi-get "/api/repos/alice/proj/issues" nil))))
3)
(gitea-issues-test
"api issues first number"
(get
(first
(dream-json-parse
(dream-resp-body (gi-get "/api/repos/alice/proj/issues" nil))))
:number)
1)
(gitea-issues-test
"api create anon 401"
(dream-status
(gi-post
"/api/repos/alice/proj/issues"
nil
(dream-json-encode {:title "t"})))
401)
(gitea-issues-test
"api create reader 201"
(dream-status
(gi-post
"/api/repos/alice/proj/issues"
"tok-e"
(dream-json-encode {:title "From eve" :body "hi"})))
201)
(gitea-issues-test
"api created number"
(len (gitea/issues gi-forge "alice" "proj"))
4)
(gitea-issues-test
"api create on private hidden 404"
(dream-status
(gi-post
"/api/repos/alice/sec/issues"
"tok-e"
(dream-json-encode {:title "x"})))
404)
(gitea-issues-test
"api create empty title 400"
(dream-status
(gi-post
"/api/repos/alice/proj/issues"
"tok-e"
(dream-json-encode {:title ""})))
400)
(gitea-issues-test
"api comment 200"
(dream-status
(gi-post
"/api/repos/alice/proj/issues/4/comments"
"tok-b"
(dream-json-encode {:body "noted"})))
200)
(gitea-issues-test
"api comment recorded"
(len (get (gitea/issue-get gi-forge "alice" "proj" 4) :comments))
1)
(gitea-issues-test
"api comment anon 401"
(dream-status
(gi-post
"/api/repos/alice/proj/issues/4/comments"
nil
(dream-json-encode {:body "x"})))
401)
(gitea-issues-test
"api comment missing issue 404"
(dream-status
(gi-post
"/api/repos/alice/proj/issues/99/comments"
"tok-b"
(dream-json-encode {:body "x"})))
404)
; eve authored #4 and may close it without write; reopen as alice (write)
(gitea-issues-test
"api close by author 200"
(dream-status (gi-post "/api/repos/alice/proj/issues/4/close" "tok-e" "{}"))
200)
(gitea-issues-test
"api close applied"
(get (gitea/issue-get gi-forge "alice" "proj" 4) :state)
"closed")
(gitea-issues-test
"api reopen by write 200"
(dream-status
(gi-post "/api/repos/alice/proj/issues/4/reopen" "tok-a" "{}"))
200)
; issue #5: authored by alice — eve (reader, not author) may not close
(gitea/issue-create!
gi-forge
"alice"
"proj"
"alice"
"Owner issue"
""
{})
(gitea-issues-test
"api close by stranger 403"
(dream-status (gi-post "/api/repos/alice/proj/issues/5/close" "tok-e" "{}"))
403)
(gitea-issues-test
"api label put by write 200"
(dream-status
(gi-put "/api/repos/alice/proj/issues/5/labels/bug" "tok-a" "{}"))
200)
(gitea-issues-test
"api label applied"
(get (gitea/issue-get gi-forge "alice" "proj" 5) :labels)
(list "bug"))
(gitea-issues-test
"api label by reader 403"
(dream-status
(gi-put "/api/repos/alice/proj/issues/5/labels/x" "tok-e" "{}"))
403)
(gitea-issues-test
"api label delete 200"
(dream-status (gi-del "/api/repos/alice/proj/issues/5/labels/bug" "tok-a"))
200)
(gitea-issues-test
"api label removed"
(get (gitea/issue-get gi-forge "alice" "proj" 5) :labels)
(list))
(gitea-issues-test
"api assign 200"
(dream-status
(gi-put "/api/repos/alice/proj/issues/5/assignees/bob" "tok-a" "{}"))
200)
(gitea-issues-test
"api assign applied"
(get (gitea/issue-get gi-forge "alice" "proj" 5) :assignees)
(list "bob"))
(gitea-issues-test
"api assign unknown user 400"
(dream-status
(gi-put "/api/repos/alice/proj/issues/5/assignees/zeb" "tok-a" "{}"))
400)
(gitea-issues-test
"api unassign 200"
(dream-status
(gi-del "/api/repos/alice/proj/issues/5/assignees/bob" "tok-a"))
200)
(gitea-issues-test
"api unassign applied"
(get (gitea/issue-get gi-forge "alice" "proj" 5) :assignees)
(list))