Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 24s
Closes the 'types define the UI' loop and adds the editor's create half. 8c (render template): a type declares a :template — a parameterised SX tree (stored as source) with (field "name") placeholders that resolve to the instance's field-values at render. host/blog-template-of / --set-template! / --instantiate (pure tree-walk) / --typed-block (per the post's types, parse+instantiate, pre-fetched in the handler). host/blog-post renders it above the body. Article seeded a subtitle standfirst template. So ONE field definition now drives BOTH the edit form AND the rendered page. create-type (metamodel editor surface 1): POST /meta/new-type creates a published post subtype-of "type" -> appears in host/blog-type-defs / the /meta Types list, ready to be given fields/schema/template. Guarded (unauthed -> login, not created). /meta gains a '+ Type' form. You can now DEFINE A TYPE THROUGH THE UI. Verified live-path: typed post's subtitle renders on its page; create 'Recipe' via the form -> Types(4). Blog suite 140/140. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
740 lines
40 KiB
Plaintext
740 lines
40 KiB
Plaintext
;; lib/host/tests/blog.sx — blog on the editor's content model. Posts are
|
||
;; {slug,title,sx_content,status} records in the durable KV; a post page is
|
||
;; render-to-html(parse sx_content). Covers read/render, home index, JSON list,
|
||
;; slugify, the form-urlencoded editor ingest, and JSON CRUD (auth+ACL guarded).
|
||
|
||
(define host-bl-pass 0)
|
||
(define host-bl-fail 0)
|
||
(define host-bl-fails (list))
|
||
(define
|
||
host-bl-test
|
||
(fn (name actual expected)
|
||
(if (= actual expected)
|
||
(set! host-bl-pass (+ host-bl-pass 1))
|
||
(begin
|
||
(set! host-bl-fail (+ host-bl-fail 1))
|
||
(append! host-bl-fails {:name name :actual actual :expected expected})))))
|
||
|
||
(define host-bl-req (fn (target) (dream-request "GET" target {} "")))
|
||
(define host-bl-app (host/make-app (list host/feed-routes host/blog-routes)))
|
||
|
||
;; ── slugify ─────────────────────────────────────────────────────────
|
||
(host-bl-test "slugify" (host/blog-slugify "Hello World") "hello-world")
|
||
(host-bl-test "slugify trims spaces" (host/blog-slugify " A B ") "a-b")
|
||
|
||
;; ── render a stored post ────────────────────────────────────────────
|
||
(host/blog-use-store! (persist/open))
|
||
(host/blog-put! "hello" "Hello World"
|
||
"(article (h1 \"Hello World\") (p \"A \" (strong \"bold\") \" word.\"))" "published")
|
||
|
||
(host-bl-test "post 200" (dream-status (host-bl-app (host-bl-req "/hello/"))) 200)
|
||
(host-bl-test "post content-type html"
|
||
(contains? (dream-resp-header (host-bl-app (host-bl-req "/hello/")) "content-type") "text/html")
|
||
true)
|
||
(host-bl-test "post renders sx_content markup"
|
||
(contains? (dream-resp-body (host-bl-app (host-bl-req "/hello/"))) "<strong>bold</strong>")
|
||
true)
|
||
(host-bl-test "post title in page"
|
||
(contains? (dream-resp-body (host-bl-app (host-bl-req "/hello/"))) "<title>Hello World</title>")
|
||
true)
|
||
|
||
;; ── home + list ─────────────────────────────────────────────────────
|
||
(host-bl-test "home lists post"
|
||
(contains? (dream-resp-body (host-bl-app (host-bl-req "/"))) "href=\"/hello/\"")
|
||
true)
|
||
(host-bl-test "sx list shows post"
|
||
(contains? (dream-resp-body (host-bl-app (host-bl-req "/posts"))) ":slug \"hello\"")
|
||
true)
|
||
(host-bl-test "GET /new shows form"
|
||
(contains? (dream-resp-body (host-bl-app (host-bl-req "/new"))) "<form")
|
||
true)
|
||
|
||
;; ── unknown + precedence ────────────────────────────────────────────
|
||
(host-bl-test "unknown slug 404" (dream-status (host-bl-app (host-bl-req "/nope/"))) 404)
|
||
(feed/reset!)
|
||
(host-bl-test "/feed not captured by :slug"
|
||
(contains? (dream-resp-body (host-bl-app (host-bl-req "/feed"))) ":ok true")
|
||
true)
|
||
|
||
;; ── writes: editor form ingest + JSON CRUD (auth+ACL) ───────────────
|
||
(acl/load! (list (acl-grant "editor" "edit" "blog")))
|
||
(define host-bl-resolve
|
||
(fn (tok) (cond ((= tok "good") "editor") ((= tok "weak") "reader") (true nil))))
|
||
(define host-bl-wapp
|
||
(host/make-app (list (host/blog-write-routes host-bl-resolve) host/blog-routes)))
|
||
(define host-bl-send
|
||
(fn (method target auth ctype body)
|
||
(dream-request method target
|
||
(merge (if auth {:authorization auth} {}) (if ctype {:content-type ctype} {})) body)))
|
||
|
||
(host/blog-use-store! (persist/open))
|
||
|
||
;; -- editor form ingest (form-urlencoded, the editor's submit shape) --
|
||
(host-bl-test "form ingest no auth -> redirect to login"
|
||
(dream-status (host-bl-wapp (host-bl-send "POST" "/new" nil
|
||
"application/x-www-form-urlencoded" "title=X")))
|
||
303)
|
||
(host-bl-test "form ingest no auth Location is /login"
|
||
(contains? (dream-resp-header (host-bl-wapp (host-bl-send "POST" "/new" nil
|
||
"application/x-www-form-urlencoded" "title=X")) "location") "/login")
|
||
true)
|
||
(host-bl-test "form ingest authed -> 303 redirect"
|
||
(dream-status (host-bl-wapp (host-bl-send "POST" "/new" "Bearer good"
|
||
"application/x-www-form-urlencoded"
|
||
"title=My+First+Post&sx_content=(article+(h1+%22My+First+Post%22)+(p+%22Hi%22))&status=published")))
|
||
303)
|
||
(host-bl-test "form ingest set Location to the new slug"
|
||
(dream-resp-header
|
||
(host-bl-wapp (host-bl-send "POST" "/new" "Bearer good"
|
||
"application/x-www-form-urlencoded"
|
||
"title=Another+One&sx_content=(p+%22x%22)&status=published"))
|
||
"location")
|
||
"/another-one/")
|
||
(host-bl-test "ingested post renders"
|
||
(contains? (dream-resp-body (host-bl-wapp (host-bl-req "/my-first-post/"))) "<h1>My First Post</h1>")
|
||
true)
|
||
|
||
;; (JSON CRUD tests removed — the /posts JSON create/update/delete endpoints were
|
||
;; deleted in the SX-native pivot; create + edit go through the form ingest above.)
|
||
|
||
;; -- write-time validation: malformed sx_content rejected, never stored --
|
||
;; "%3Ch1+broken%29" decodes to "<h1 broken)" — a typo'd paren the parser rejects.
|
||
(host-bl-test "form ingest malformed sx_content -> 400"
|
||
(dream-status (host-bl-wapp (host-bl-send "POST" "/new" "Bearer good"
|
||
"application/x-www-form-urlencoded"
|
||
"title=Bad+Form&sx_content=%3Ch1+broken%29&status=published")))
|
||
400)
|
||
(host-bl-test "rejected form post was not stored"
|
||
(dream-status (host-bl-wapp (host-bl-req "/bad-form/")))
|
||
404)
|
||
;; (JSON malformed-content tests removed with the JSON CRUD endpoints; the form
|
||
;; ingest malformed-content checks above still cover write-time validation.)
|
||
|
||
;; -- view source (public) --
|
||
(host-bl-test "view source -> 200"
|
||
(dream-status (host-bl-wapp (host-bl-req "/my-first-post/source"))) 200)
|
||
(host-bl-test "view source is text/plain"
|
||
(dream-resp-header (host-bl-wapp (host-bl-req "/my-first-post/source")) "content-type")
|
||
"text/plain; charset=utf-8")
|
||
(host-bl-test "view source returns raw sx_content"
|
||
(contains? (dream-resp-body (host-bl-wapp (host-bl-req "/my-first-post/source"))) "(article")
|
||
true)
|
||
(host-bl-test "view source missing -> 404"
|
||
(dream-status (host-bl-wapp (host-bl-req "/ghost/source"))) 404)
|
||
(host-bl-test "/:slug not shadowed by /:slug/source"
|
||
(dream-status (host-bl-wapp (host-bl-req "/my-first-post/"))) 200)
|
||
|
||
;; -- edit source (guarded GET form + guarded POST save) --
|
||
(host-bl-test "edit form no auth -> redirect to login"
|
||
(dream-status (host-bl-wapp (host-bl-send "GET" "/my-first-post/edit" nil "" ""))) 303)
|
||
(host-bl-test "edit form no auth Location carries next=/…/edit"
|
||
(contains?
|
||
(dream-resp-header (host-bl-wapp (host-bl-send "GET" "/my-first-post/edit" nil "" "")) "location")
|
||
"/login?next=/my-first-post/edit")
|
||
true)
|
||
(host-bl-test "edit form authed -> 200"
|
||
(dream-status (host-bl-wapp (host-bl-send "GET" "/my-first-post/edit" "Bearer good" "" ""))) 200)
|
||
(host-bl-test "edit form shows current source"
|
||
(contains? (dream-resp-body (host-bl-wapp (host-bl-send "GET" "/my-first-post/edit" "Bearer good" "" "")))
|
||
"(article")
|
||
true)
|
||
(host-bl-test "edit submit no auth -> redirect to login"
|
||
(dream-status (host-bl-wapp (host-bl-send "POST" "/my-first-post/edit" nil
|
||
"application/x-www-form-urlencoded" "sx_content=(p+%22x%22)"))) 303)
|
||
(host-bl-test "edit submit authed -> 303"
|
||
(dream-status (host-bl-wapp (host-bl-send "POST" "/my-first-post/edit" "Bearer good"
|
||
"application/x-www-form-urlencoded"
|
||
"title=My+First+Post&sx_content=(p+%22edited+via+editor%22)&status=published"))) 303)
|
||
(host-bl-test "edit persisted the new content"
|
||
(contains? (dream-resp-body (host-bl-wapp (host-bl-req "/my-first-post/"))) "edited via editor")
|
||
true)
|
||
(host-bl-test "edit preserves the slug"
|
||
(dream-resp-header
|
||
(host-bl-wapp (host-bl-send "POST" "/my-first-post/edit" "Bearer good"
|
||
"application/x-www-form-urlencoded" "title=Renamed&sx_content=(p+%22y%22)&status=draft"))
|
||
"location")
|
||
"/my-first-post/")
|
||
(host-bl-test "edit malformed body -> 400"
|
||
(dream-status (host-bl-wapp (host-bl-send "POST" "/my-first-post/edit" "Bearer good"
|
||
"application/x-www-form-urlencoded" "sx_content=%3Ch1+broken%29"))) 400)
|
||
(host-bl-test "edit missing post -> 404"
|
||
(dream-status (host-bl-wapp (host-bl-send "GET" "/ghost/edit" "Bearer good" "" ""))) 404)
|
||
|
||
;; -- auth footer (discoverable login/logout) --
|
||
(host-bl-test "home footer shows a log in link when anonymous"
|
||
(contains? (dream-resp-body (host-bl-app (host-bl-req "/"))) ">log in</a>") true)
|
||
(host-bl-test "post footer shows a log in link when anonymous"
|
||
(contains? (dream-resp-body (host-bl-app (host-bl-req "/my-first-post/"))) ">log in</a>") true)
|
||
(host-bl-test "GET /logout -> 303"
|
||
(dream-status (host-bl-app (host-bl-req "/logout"))) 303)
|
||
|
||
;; -- relate posts (blog × relations) --
|
||
;; my-first-post and another-one both exist in the write-test store at this point.
|
||
;; Relations are posts now (their symmetry/labels live on relation-posts), so seed
|
||
;; them up front exactly as boot does (serve.sh) before exercising relate, and load
|
||
;; the relation metadata into the in-memory cache the same way.
|
||
(host/blog-seed-types!)
|
||
(host/blog-load-rel-kinds!)
|
||
(host-bl-test "relate no auth -> redirect to login"
|
||
(dream-status (host-bl-wapp (host-bl-send "POST" "/my-first-post/relate" nil
|
||
"application/x-www-form-urlencoded" "other=another-one"))) 303)
|
||
(host-bl-test "relate authed -> 303 back to edit"
|
||
(dream-resp-header (host-bl-wapp (host-bl-send "POST" "/my-first-post/relate" "Bearer good"
|
||
"application/x-www-form-urlencoded" "other=another-one")) "location")
|
||
"/my-first-post/edit")
|
||
(host-bl-test "related is symmetric (a -> b)"
|
||
(contains? (host/blog-related "my-first-post") "another-one") true)
|
||
(host-bl-test "related is symmetric (b -> a)"
|
||
(contains? (host/blog-related "another-one") "my-first-post") true)
|
||
(host-bl-test "post page shows a Related posts block"
|
||
(contains? (dream-resp-body (host-bl-wapp (host-bl-req "/my-first-post/"))) "Related posts") true)
|
||
(host-bl-test "post page links the related post"
|
||
(contains? (dream-resp-body (host-bl-wapp (host-bl-req "/my-first-post/"))) "/another-one/") true)
|
||
(host-bl-test "relate nonexistent other -> no-op"
|
||
(begin
|
||
(host-bl-wapp (host-bl-send "POST" "/my-first-post/relate" "Bearer good"
|
||
"application/x-www-form-urlencoded" "other=ghost-post"))
|
||
(contains? (host/blog-related "my-first-post") "ghost-post"))
|
||
false)
|
||
(host-bl-test "unrelate -> removes the link both ways"
|
||
(begin
|
||
(host-bl-wapp (host-bl-send "POST" "/my-first-post/unrelate" "Bearer good"
|
||
"application/x-www-form-urlencoded" "other=another-one"))
|
||
(list (contains? (host/blog-related "my-first-post") "another-one")
|
||
(contains? (host/blog-related "another-one") "my-first-post")))
|
||
(list false false))
|
||
;; (The "delete cleans up related edges" test was removed with the JSON DELETE
|
||
;; /posts endpoint; cascade edge cleanup returns when a browser delete route is
|
||
;; added — see the FOLLOW-UP note in lib/host/blog.sx.)
|
||
|
||
;; -- relate picker (filterable candidate endpoint + glue + hint) --
|
||
(host/blog-put! "alpha-post" "Alpha Post" "(p \"a\")" "published")
|
||
(host/blog-put! "beta-post" "Beta Post" "(p \"b\")" "published")
|
||
(host/blog-put! "gamma-post" "Gamma Post" "(p \"g\")" "published")
|
||
(host-bl-test "relate-options lists other posts"
|
||
(contains? (dream-resp-body (host-bl-app (host-bl-req "/alpha-post/relate-options"))) "Beta Post") true)
|
||
(host-bl-test "relate-options excludes the post itself"
|
||
(contains? (dream-resp-body (host-bl-app (host-bl-req "/alpha-post/relate-options"))) ">Alpha Post<") false)
|
||
(host-bl-test "relate-options filters by q (title substring)"
|
||
(let ((body (dream-resp-body (host-bl-app (host-bl-req "/alpha-post/relate-options?q=beta")))))
|
||
(list (contains? body "Beta Post") (contains? body "Gamma Post")))
|
||
(list true false))
|
||
(host-bl-test "relate-options filter url-decodes q (spaces)"
|
||
(let ((body (dream-resp-body (host-bl-app (host-bl-req "/alpha-post/relate-options?q=Beta%20Post")))))
|
||
(list (contains? body "Beta Post") (contains? body "Gamma Post")))
|
||
(list true false))
|
||
(host-bl-test "relate-options excludes already-related candidates"
|
||
(begin
|
||
(host/blog-relate! "alpha-post" "beta-post" "related")
|
||
(contains? (dream-resp-body (host-bl-app (host-bl-req "/alpha-post/relate-options"))) "Beta Post"))
|
||
false)
|
||
(host/blog-unrelate! "alpha-post" "beta-post" "related")
|
||
;; The picker is a declarative SX-htmx form (no client JS): the form GETs
|
||
;; relate-options serialising kind + the filter q, swapping the results ul on
|
||
;; "load" and on debounced "input". The SX engine re-binds these triggers on
|
||
;; swapped content, so it works on a full load AND a boosted SPA nav.
|
||
(host-bl-test "picker form is declaratively wired to relate-options (load + debounced input)"
|
||
(let ((html (render-page (host/blog--relation-editor "alpha-post" "related" true))))
|
||
(list (contains? html "/alpha-post/relate-options")
|
||
(contains? html "input delay:200ms, load")
|
||
(contains? html "rp-related-results")))
|
||
(list true true true))
|
||
;; the editor server-renders the first page of candidates INTO the picker's results
|
||
;; <ul>, so a re-rendered editor is never briefly empty (no flash). The candidate row
|
||
;; for an existing post appears inside the results ul.
|
||
(host-bl-test "editor server-renders the first page of candidates into the picker"
|
||
(let ((html (render-page (host/blog--relation-editor "alpha-post" "related" true))))
|
||
(list (contains? html "id=\"cand-related-") ;; a candidate row is present
|
||
(contains? html "Beta Post"))) ;; an unrelated post is offered
|
||
(list true true))
|
||
;; Paging is server-driven: a full page carries a "load more" sentinel that, when
|
||
;; revealed, GETs the next page and replaces itself (outerHTML), preserving q.
|
||
(host-bl-test "load-more sentinel: revealed, outerHTML-swap, next offset, preserved q"
|
||
(let ((html (render-page (host/blog--picker-more "alpha-post" "related" "my q" 20))))
|
||
(list (contains? html "rp-more")
|
||
(contains? html "revealed")
|
||
(contains? html "outerHTML")
|
||
(contains? html "offset=20")
|
||
(contains? html "q=my%20q")
|
||
(contains? html "exponential:1000:30000"))) ;; retries a dropped fetch
|
||
(list true true true true true true))
|
||
(host-bl-test "relate-options omits the load-more sentinel on a short last page"
|
||
(contains? (dream-resp-body (host-bl-app (host-bl-req "/alpha-post/relate-options"))) "rp-more")
|
||
false)
|
||
|
||
;; -- relate / unrelate keep BOTH lists in sync by re-rendering the kind's editor.
|
||
;; Regressions: (1) relating a candidate must ADD it to the current-relations
|
||
;; list (not just delete the candidate row); (2) removing must NOT clear the
|
||
;; relate picker. Both the candidate's relate form and the remove form target
|
||
;; #rel-editor-KIND with sx-swap=outerHTML; the handler returns the re-rendered
|
||
;; editor, so the current list updates and the fresh picker re-loads. --
|
||
(host/blog-relate! "alpha-post" "beta-post" "related")
|
||
;; the editor wraps current list + picker in #rel-editor-KIND; remove re-renders it
|
||
(host-bl-test "relation-editor wires remove to re-render the kind's editor"
|
||
(let ((html (render-page (host/blog--relation-editor "alpha-post" "related" true))))
|
||
(list (contains? html "id=\"rel-editor-related\"") ;; the swap target
|
||
(contains? html "sx-post=\"/alpha-post/unrelate\"") ;; AJAX, not plain post
|
||
(contains? html "sx-target=\"#rel-editor-related\"")
|
||
(contains? html "sx-swap=\"outerHTML\"")))
|
||
(list true true true true))
|
||
;; the candidate's relate form targets the SAME editor (so relating re-renders it)
|
||
(host-bl-test "picker candidate relate form re-renders the kind's editor"
|
||
(let ((html (render-page (host/blog--picker-item "alpha-post" {:slug "gamma-post" :title "Gamma"} "related"))))
|
||
(list (contains? html "sx-post=\"/alpha-post/relate\"")
|
||
(contains? html "sx-target=\"#rel-editor-related\"")
|
||
(contains? html "sx-swap=\"outerHTML\"")))
|
||
(list true true true))
|
||
;; a POST request to a /:slug/… route, with the :slug route param populated (which
|
||
;; the route matcher would set) plus headers + a form body.
|
||
(define host-bl-relreq
|
||
(fn (slug action headers other kind)
|
||
(merge (dream-request "POST" (str "/" slug "/" action) headers
|
||
(str "other=" other "&kind=" kind))
|
||
{:params {:slug slug}})))
|
||
;; the AJAX remove (carries SX-Target) returns the re-rendered editor fragment (200,
|
||
;; with the #rel-editor wrapper + the picker) — not an empty body or a redirect.
|
||
(host-bl-test "unrelate (AJAX, SX-Target) returns the re-rendered editor fragment"
|
||
(let ((resp (host/blog-unrelate-submit
|
||
(host-bl-relreq "alpha-post" "unrelate"
|
||
{:sx-request "true" :sx-target "#rel-editor-related"}
|
||
"beta-post" "related"))))
|
||
(list (dream-status resp)
|
||
(contains? (dream-resp-body resp) "rel-editor-related")
|
||
(contains? (dream-resp-body resp) "relate-picker")))
|
||
(list 200 true true))
|
||
;; relate (AJAX, SX-Target) likewise returns the editor with the new relation listed
|
||
(host/blog-unrelate! "alpha-post" "gamma-post" "related") ;; clean state
|
||
(host-bl-test "relate (AJAX, SX-Target) returns the editor showing the new relation"
|
||
(let ((resp (host/blog-relate-submit
|
||
(host-bl-relreq "alpha-post" "relate"
|
||
{:sx-request "true" :sx-target "#rel-editor-related"}
|
||
"gamma-post" "related"))))
|
||
(list (dream-status resp)
|
||
(contains? (dream-resp-body resp) "/gamma-post/"))) ;; now in the current list
|
||
(list 200 true))
|
||
(host/blog-unrelate! "alpha-post" "gamma-post" "related")
|
||
;; a plain boosted form / no-JS POST (no SX-Target) still redirects + re-renders,
|
||
;; so the is-a-tag toggle and graceful degradation are unaffected.
|
||
(host-bl-test "unrelate (plain boosted / no-JS, no SX-Target) still redirects"
|
||
(dream-status (host/blog-unrelate-submit
|
||
(host-bl-relreq "alpha-post" "unrelate"
|
||
{:sx-request "true"} "beta-post" "related")))
|
||
303)
|
||
(host/blog-unrelate! "alpha-post" "beta-post" "related")
|
||
(host/blog-put! "hint-post" "Hint Post" "(p \"h\")" "published")
|
||
(host-bl-test "relations section: hint when logged-in + no relations"
|
||
(contains? (str (host/blog--relations-or-hint "hint-post" true)) "add some") true)
|
||
(host-bl-test "relations section: empty when anonymous + no relations"
|
||
(= (host/blog--relations-or-hint "hint-post" false) "") true)
|
||
|
||
;; -- Phase 1: relations carry a kind --
|
||
(host-bl-test "symmetric kind (related) reads from both sides"
|
||
(begin
|
||
(host/blog-relate! "alpha-post" "gamma-post" "related")
|
||
(list (contains? (host/blog-out "alpha-post" "related") "gamma-post")
|
||
(contains? (host/blog-out "gamma-post" "related") "alpha-post")))
|
||
(list true true))
|
||
(host-bl-test "directed kind (tagged) writes one direction; inverse via host/blog-in"
|
||
(begin
|
||
(host/blog-relate! "alpha-post" "beta-post" "tagged")
|
||
(list (contains? (host/blog-out "alpha-post" "tagged") "beta-post")
|
||
(contains? (host/blog-out "beta-post" "tagged") "alpha-post")
|
||
(contains? (host/blog-in "beta-post" "tagged") "alpha-post")))
|
||
(list true false true))
|
||
(host-bl-test "unrelate is kind-scoped (related edge survives a tagged unrelate)"
|
||
(begin
|
||
(host/blog-unrelate! "alpha-post" "beta-post" "tagged")
|
||
(list (contains? (host/blog-out "alpha-post" "tagged") "beta-post")
|
||
(contains? (host/blog-out "alpha-post" "related") "gamma-post")))
|
||
(list false true))
|
||
(host/blog-unrelate! "alpha-post" "gamma-post" "related")
|
||
(host-bl-test "relate-submit rejects an unknown kind (no-op)"
|
||
(begin
|
||
(host-bl-wapp (host-bl-send "POST" "/alpha-post/relate" "Bearer good"
|
||
"application/x-www-form-urlencoded" "other=beta-post&kind=bogus"))
|
||
(contains? (host/blog-out "alpha-post" "bogus") "beta-post"))
|
||
false)
|
||
(host-bl-test "default kind is related (no kind field)"
|
||
(begin
|
||
(host-bl-wapp (host-bl-send "POST" "/alpha-post/relate" "Bearer good"
|
||
"application/x-www-form-urlencoded" "other=beta-post"))
|
||
(contains? (host/blog-out "alpha-post" "related") "beta-post"))
|
||
true)
|
||
(host-bl-test "edges are durable: KV row written on relate"
|
||
(begin
|
||
(host/blog-relate! "alpha-post" "gamma-post" "tagged")
|
||
(persist/backend-kv-has? host/blog-store (host/blog--edge-key "alpha-post" "tagged" "gamma-post")))
|
||
true)
|
||
(host-bl-test "replay rebuilds the graph after an in-memory wipe (restart sim)"
|
||
(begin
|
||
(relations/load! (list)) ;; simulate a fresh process
|
||
(host/blog-load-edges!) ;; replay from the durable store
|
||
(list (contains? (host/blog-out "alpha-post" "tagged") "gamma-post")
|
||
(contains? (host/blog-out "alpha-post" "related") "beta-post")
|
||
(contains? (host/blog-out "beta-post" "related") "alpha-post")))
|
||
(list true true true))
|
||
(host-bl-test "unrelate deletes the durable KV row"
|
||
(begin
|
||
(host/blog-unrelate! "alpha-post" "gamma-post" "tagged")
|
||
(persist/backend-kv-has? host/blog-store (host/blog--edge-key "alpha-post" "tagged" "gamma-post")))
|
||
false)
|
||
|
||
;; -- Phase 2: typing with subsumption (is-a + subtype-of) --
|
||
;; ppost --is-a--> ptutorial ; ptutorial --subtype-of--> particle --subtype-of--> pdoc
|
||
(host/blog-put! "ptutorial" "P Tutorial" "(p \"t\")" "published")
|
||
(host/blog-put! "particle" "P Article" "(p \"a\")" "published")
|
||
(host/blog-put! "pdoc" "P Doc" "(p \"d\")" "published")
|
||
(host/blog-put! "ppost" "P Post" "(p \"p\")" "published")
|
||
(host/blog-relate! "ptutorial" "particle" "subtype-of")
|
||
(host/blog-relate! "particle" "pdoc" "subtype-of")
|
||
(host/blog-relate! "ppost" "ptutorial" "is-a")
|
||
(host-bl-test "types-of = declared type + ALL its subtype-of supertypes"
|
||
(list (contains? (host/blog-types-of "ppost") "ptutorial")
|
||
(contains? (host/blog-types-of "ppost") "particle")
|
||
(contains? (host/blog-types-of "ppost") "pdoc"))
|
||
(list true true true))
|
||
(host-bl-test "is-a? is transitive THROUGH subtype-of (subsumption)"
|
||
(list (host/blog-is-a? "ppost" "ptutorial")
|
||
(host/blog-is-a? "ppost" "pdoc"))
|
||
(list true true))
|
||
(host-bl-test "is-a? alone does NOT chain (instance-of is not transitive)"
|
||
(begin
|
||
(host/blog-put! "pmeta" "P Meta" "(p \"m\")" "published")
|
||
(host/blog-relate! "pmeta" "ppost" "is-a") ;; pmeta is-a ppost is-a ptutorial
|
||
(host/blog-is-a? "pmeta" "ptutorial")) ;; ... does NOT make pmeta is-a ptutorial
|
||
false)
|
||
(host-bl-test "is-a? false for an unrelated type"
|
||
(host/blog-is-a? "ppost" "particle") true) ;; sanity: this one IS reachable
|
||
(host-bl-test "seed-types: an instance of tag is, transitively, a type"
|
||
(begin
|
||
(host/blog-seed-types!) ;; type, tag, tag subtype-of type
|
||
(host/blog-put! "ocaml" "OCaml" "(p \"lang\")" "published")
|
||
(host/blog-relate! "ocaml" "tag" "is-a") ;; ocaml is-a tag
|
||
(list (host/blog-is-a? "ocaml" "tag") (host/blog-is-a? "ocaml" "type")))
|
||
(list true true))
|
||
(host-bl-test "type-valid? is vacuously true with no schemas (gradual)"
|
||
(host/blog-type-valid? "ppost" "(p \"anything\")") true)
|
||
|
||
;; -- relations-as-posts: declaration-driven candidate pools (plans/relations-as-posts.md) --
|
||
;; The picker's candidate set is the down-closure of a relation's anchors. is-a/subtype-of
|
||
;; are anchored by `type`, so they offer the WHOLE type closure — the roots (type/tag/
|
||
;; article) AND the instances — fixing the wrinkle where only instances showed.
|
||
(host-bl-test "is-a candidates = the type closure: roots (type/tag/article) AND instances"
|
||
(let ((pool (host/blog--candidate-pool "is-a")))
|
||
(list (contains? pool "type") (contains? pool "tag")
|
||
(contains? pool "article") (contains? pool "ocaml"))) ;; ocaml is-a tag
|
||
(list true true true true))
|
||
(host-bl-test "is-a candidates exclude a plain content post (not is-a/subtype-reachable to Type)"
|
||
(contains? (host/blog--candidate-pool "is-a") "ppost") false)
|
||
(host-bl-test "tagged candidates are anchored by tag (tag + its instances)"
|
||
(let ((pool (host/blog--candidate-pool "tagged")))
|
||
(list (contains? pool "tag") (contains? pool "ocaml")))
|
||
(list true true))
|
||
(host-bl-test "related candidates = every post (no declaration anchors it)"
|
||
(let ((pool (host/blog--candidate-pool "related")))
|
||
(list (contains? pool "type") (contains? pool "ppost")))
|
||
(list true true))
|
||
;; and it flows through to the live picker endpoint: the is-a picker now offers a type root
|
||
(host-bl-test "is-a relate-options offers the type roots (Article)"
|
||
(contains? (dream-resp-body (host-bl-app (host-bl-req "/ppost/relate-options?kind=is-a"))) "Article")
|
||
true)
|
||
|
||
;; -- relations are posts: symmetry + labels read off the relation-posts (slice 2) --
|
||
(host-bl-test "kind-spec reads :rel metadata off the relation-post"
|
||
(let ((s (host/blog--kind-spec "is-a")))
|
||
(list (get s :kind) (get s :label) (get s :symmetric) (get s :inverse-label)))
|
||
(list "is-a" "Types" false "Instances"))
|
||
(host-bl-test "kind-symmetric? reads symmetry off the post (related yes, is-a no)"
|
||
(list (host/blog--kind-symmetric? "related") (host/blog--kind-symmetric? "is-a"))
|
||
(list true false))
|
||
(host-bl-test "an unknown kind has no spec, so relate still validates it away"
|
||
(host/blog--kind-spec "bogus-kind") nil)
|
||
(host-bl-test "rel-kinds is DERIVED from the graph (every post that is-a relation)"
|
||
(let ((kinds (map (fn (s) (get s :kind)) host/blog-rel-kinds)))
|
||
(list (contains? kinds "related") (contains? kinds "is-a")
|
||
(contains? kinds "subtype-of") (contains? kinds "tagged")))
|
||
(list true true true true))
|
||
|
||
;; -- relations are TYPED: the target-type constraint is enforced (slice 3) --
|
||
;; A valid object of a relation is one in its declared candidate set (the picker's
|
||
;; pool). So is-a's object must be a type, tagged's must be a tag, related's any post.
|
||
(host-bl-test "valid-object?: is-a accepts a type (article), rejects a plain post (ppost)"
|
||
(list (host/blog--valid-object? "is-a" "article") (host/blog--valid-object? "is-a" "ppost"))
|
||
(list true false))
|
||
(host-bl-test "valid-object?: tagged accepts a tag (ocaml); related accepts any post"
|
||
(list (host/blog--valid-object? "tagged" "ocaml") (host/blog--valid-object? "related" "ppost"))
|
||
(list true true))
|
||
;; the relate ENDPOINT enforces it: is-a to a type relates; is-a to a non-type no-ops.
|
||
(host/blog-unrelate! "alpha-post" "article" "is-a")
|
||
(host-bl-test "relate-submit: is-a to a type (article) creates the edge"
|
||
(begin
|
||
(host/blog-relate-submit (host-bl-relreq "alpha-post" "relate"
|
||
{:sx-request "true" :sx-target "#rel-editor-is-a"} "article" "is-a"))
|
||
(contains? (host/blog-out "alpha-post" "is-a") "article"))
|
||
true)
|
||
(host/blog-unrelate! "alpha-post" "article" "is-a")
|
||
(host-bl-test "relate-submit: is-a to a NON-type (beta-post) is rejected (no edge)"
|
||
(begin
|
||
(host/blog-relate-submit (host-bl-relreq "alpha-post" "relate"
|
||
{:sx-request "true" :sx-target "#rel-editor-is-a"} "beta-post" "is-a"))
|
||
(contains? (host/blog-out "alpha-post" "is-a") "beta-post"))
|
||
false)
|
||
|
||
;; -- Slice 4: type ALGEBRA — intersection (∧) and union (∨) types --
|
||
;; ocaml is-a tag (seeded above); make it is-a article too, so it's in BOTH extents.
|
||
(host/blog-relate! "ocaml" "article" "is-a")
|
||
(host/blog-make-and! "taggy-article" "tag" "article") ;; tag ∧ article
|
||
(host/blog-make-or! "tag-or-article" "tag" "article") ;; tag ∨ article
|
||
(host-bl-test "intersection (∧): a member iff it's an instance of BOTH operands"
|
||
(list (host/blog-is-a-expr? "ocaml" "taggy-article") ;; is-a tag AND is-a article
|
||
(host/blog-is-a-expr? "ppost" "taggy-article")) ;; neither
|
||
(list true false))
|
||
(host-bl-test "union (∨): a member iff it's an instance of EITHER operand"
|
||
(list (host/blog-is-a-expr? "ocaml" "tag-or-article") ;; is-a tag (and article)
|
||
(host/blog-is-a-expr? "ppost" "tag-or-article")) ;; neither tag nor article
|
||
(list true false))
|
||
(host-bl-test "the extent is the set intersection of the operands' extents"
|
||
(let ((ext (host/blog-instances-of-expr "taggy-article")))
|
||
(list (contains? ext "ocaml") ;; in tag ∩ article
|
||
(contains? ext "ppost"))) ;; in neither
|
||
(list true false))
|
||
;; algebra is META-CIRCULAR: an operand can itself be an algebraic type.
|
||
(host/blog-make-and! "nested-and" "taggy-article" "tag") ;; (tag ∧ article) ∧ tag
|
||
(host-bl-test "nested type expression: (tag ∧ article) ∧ tag still admits ocaml"
|
||
(host/blog-is-a-expr? "ocaml" "nested-and") true)
|
||
|
||
;; -- Slice 5: refinement types — schemas live ON the type-post --
|
||
;; article's schema (now on the article post) is still enforced for its instances.
|
||
(host/blog-put! "art-test" "Art Test" "(p \"x\")" "published")
|
||
(host/blog-relate! "art-test" "article" "is-a")
|
||
(host-bl-test "article (refinement type, schema on the post) requires an h1"
|
||
(list (host/blog-type-valid? "art-test" "(p \"no heading\")") ;; missing h1
|
||
(host/blog-type-valid? "art-test" "(article (h1 \"H\") (p \"x\"))")) ;; has h1
|
||
(list false true))
|
||
;; a NEW refinement type is pure data: give a type-post a :schema and its instances
|
||
;; are validated against it — no code, no hardcoded table.
|
||
(host/blog-seed! "guide" "Guide" "(article (h1 \"Guide\") (p \"A guide.\"))" "published")
|
||
(host/blog-relate! "guide" "type" "subtype-of")
|
||
(host/blog--set-schema! "guide" (list {:block "pre" :msg "a guide needs a code block (pre)"}))
|
||
(host/blog-put! "g1" "G1" "(p \"x\")" "published")
|
||
(host/blog-relate! "g1" "guide" "is-a")
|
||
(host-bl-test "a NEW refinement type validates its instances against its :schema"
|
||
(list (host/blog-type-valid? "g1" "(p \"no code\")") ;; missing pre
|
||
(host/blog-type-valid? "g1" "(article (pre \"x\") (p \"y\"))")) ;; has pre
|
||
(list false true))
|
||
(host-bl-test "the schema is read off the type-post (data, not a hardcoded table)"
|
||
(contains? (str (host/blog-schema-of "guide")) "code block") true)
|
||
;; editing a refinement type preserves its :schema (put! merges over the record).
|
||
(host/blog-put! "guide" "Guide v2" "(article (h1 \"Guide\") (p \"edited\"))" "published")
|
||
(host-bl-test "editing a type-post preserves its :schema (and metadata survives edits)"
|
||
(contains? (str (host/blog-schema-of "guide")) "code block") true)
|
||
|
||
;; -- Phase 3: tags as posts -- (ocaml is-a tag, from the seed-types test above)
|
||
(host-bl-test "is-tag?: a post that is-a tag is a tag; others are not"
|
||
(list (host/blog-is-tag? "ocaml") (host/blog-is-tag? "ppost"))
|
||
(list true false))
|
||
(host-bl-test "instances-of tag includes the tag posts"
|
||
(contains? (host/blog-instances-of "tag") "ocaml") true)
|
||
(host-bl-test "tag a post: it appears in tags + tagged-with (inverse)"
|
||
(begin
|
||
(host/blog-relate! "ppost" "ocaml" "tagged") ;; ppost tagged ocaml
|
||
(list (contains? (host/blog-tags "ppost") "ocaml")
|
||
(contains? (host/blog-tagged-with "ocaml") "ppost")))
|
||
(list true true))
|
||
(host-bl-test "tagged picker offers only tags (kind=tagged)"
|
||
(let ((body (dream-resp-body (host-bl-app (host-bl-req "/particle/relate-options?kind=tagged")))))
|
||
(list (contains? body ">OCaml<") (contains? body ">P Article<")))
|
||
(list true false))
|
||
(host-bl-test "related picker still offers all posts (kind defaults to related)"
|
||
(contains? (dream-resp-body (host-bl-app (host-bl-req "/particle/relate-options"))) ">P Doc<")
|
||
true)
|
||
(host-bl-test "is-a-tag toggle marks a post a tag via /relate kind=is-a"
|
||
(begin
|
||
(host-bl-wapp (host-bl-send "POST" "/pdoc/relate" "Bearer good"
|
||
"application/x-www-form-urlencoded" "other=tag&kind=is-a"))
|
||
(host/blog-is-tag? "pdoc"))
|
||
true)
|
||
|
||
;; -- Phase 4: registry-driven render + /tags index --
|
||
(host-bl-test "relation-blocks renders Related + Tags from the registry"
|
||
(begin
|
||
(host/blog-relate! "hint-post" "ppost" "related")
|
||
(host/blog-relate! "hint-post" "ocaml" "tagged")
|
||
(let ((body (str (host/blog--relation-blocks "hint-post"))))
|
||
(list (contains? body "Related posts") (contains? body "Tags"))))
|
||
(list true true))
|
||
(host-bl-test "relation-blocks shows an inverse block (Tagged with this) for a tag"
|
||
(contains? (str (host/blog--relation-blocks "ocaml")) "Tagged with this") true)
|
||
(host-bl-test "/tags lists the tag posts"
|
||
(contains? (dream-resp-body (host-bl-app (host-bl-req "/tags"))) "OCaml") true)
|
||
(host-bl-test "/tags is 200 (not shadowed by /:slug)"
|
||
(dream-status (host-bl-app (host-bl-req "/tags"))) 200)
|
||
|
||
;; -- Phase 6: gradual schema validation --
|
||
(host/blog-seed-types!) ;; ensures the "article" type + its schema (requires h1)
|
||
(host-bl-test "all-tags finds nested element tags"
|
||
(let ((tags (host/blog--all-tags (parse-safe "(article (h1 \"T\") (p \"x\"))"))))
|
||
(list (contains? tags "h1") (contains? tags "p") (contains? tags "section")))
|
||
(list true true false))
|
||
(host-bl-test "schema-issues: missing required block -> 1 issue; present -> 0"
|
||
(let ((sch (host/blog-schema-of "article")))
|
||
(list (len (host/blog--schema-issues sch "(p \"no heading\")"))
|
||
(len (host/blog--schema-issues sch "(article (h1 \"yes\"))"))))
|
||
(list 1 0))
|
||
(host-bl-test "type-valid? enforces an is-a article's schema"
|
||
(begin
|
||
(host/blog-put! "art1" "Art 1" "(p \"x\")" "published")
|
||
(host/blog-relate! "art1" "article" "is-a")
|
||
(list (host/blog-type-valid? "art1" "(p \"no heading\")")
|
||
(host/blog-type-valid? "art1" "(article (h1 \"H\") (p \"x\"))")))
|
||
(list false true))
|
||
|
||
;; -- metamodel overview (GET /meta) --
|
||
(host-bl-test "type-defs = the subtype hierarchy (type defs), not is-a instances"
|
||
(let ((defs (host/blog-type-defs)))
|
||
(list (contains? defs "type") (contains? defs "article") (contains? defs "art1")))
|
||
(list true true false))
|
||
(host-bl-test "/meta is 200 (not shadowed by /:slug)"
|
||
(dream-status (host-bl-app (host-bl-req "/meta"))) 200)
|
||
(host-bl-test "/meta lists type definitions + relations + the article's required block"
|
||
(let ((body (dream-resp-body (host-bl-app (host-bl-req "/meta")))))
|
||
(list (contains? body "Metamodel") (contains? body "Article")
|
||
(contains? body "h1") (contains? body "related")
|
||
(contains? body "symmetric")))
|
||
(list true true true true true))
|
||
|
||
;; -- Slice 8: typed scalar fields on a type --
|
||
(host-bl-test "fields-of reads a type's declared fields (seeded on article)"
|
||
(map (fn (f) (get f :name)) (host/blog-fields-of "article"))
|
||
(list "subtitle" "hero"))
|
||
(host-bl-test "widget-for: explicit > value-type default > text fallback"
|
||
(list (host/blog--widget-for {:name "a" :type "URL"})
|
||
(host/blog--widget-for {:name "b" :type "Text"})
|
||
(host/blog--widget-for {:name "c" :type "Nonsense"})
|
||
(host/blog--widget-for {:name "d" :type "String" :widget "custom"}))
|
||
(list "url" "textarea" "text" "custom"))
|
||
(host-bl-test "set-fields! is idempotent + preserves the rest of the record"
|
||
(begin
|
||
(host/blog--set-fields! "article"
|
||
(list {:name "subtitle" :type "String"} {:name "hero" :type "URL"}))
|
||
(list (get (host/blog-get "article") :title) (len (host/blog-fields-of "article"))))
|
||
(list "Article" 2))
|
||
(host-bl-test "a type with no declared fields -> empty list"
|
||
(host/blog-fields-of "tag") (list))
|
||
(host-bl-test "/meta shows the article's typed fields"
|
||
(contains? (dream-resp-body (host-bl-app (host-bl-req "/meta"))) "subtitle:String") true)
|
||
|
||
;; -- Slice 8b: field values + the generic, type-driven edit form --
|
||
(host-bl-test "fields-for-post = union of the post's (transitive) types' fields"
|
||
(begin
|
||
(host/blog-put! "fpost" "F Post" "(article (h1 \"F\"))" "published")
|
||
(host/blog-relate! "fpost" "article" "is-a")
|
||
(map (fn (f) (get f :name)) (host/blog--fields-for-post "fpost")))
|
||
(list "subtitle" "hero"))
|
||
(host-bl-test "a post of no typed type has no fields"
|
||
(host/blog--fields-for-post "hello") (list))
|
||
(host-bl-test "set/get field-values round-trips on an instance"
|
||
(begin
|
||
(host/blog--set-field-values! "fpost" {"subtitle" "A subtitle" "hero" "http://x/y.png"})
|
||
(list (get (host/blog-field-values-of "fpost") "subtitle")
|
||
(get (host/blog-field-values-of "fpost") "hero")))
|
||
(list "A subtitle" "http://x/y.png"))
|
||
(host-bl-test "edit form renders one input per field for a typed post"
|
||
(let ((body (dream-resp-body (host-bl-wapp (host-bl-send "GET" "/fpost/edit" "Bearer good" nil "")))))
|
||
(list (contains? body "field-subtitle") (contains? body "field-hero") (contains? body "Fields")))
|
||
(list true true true))
|
||
(host-bl-test "edit-submit stores the typed field values from the form"
|
||
(begin
|
||
(host-bl-wapp (host-bl-send "POST" "/fpost/edit" "Bearer good"
|
||
"application/x-www-form-urlencoded"
|
||
"sx_content=(article+(h1+%22F%22))&field-subtitle=Saved+Sub&field-hero=http%3A%2F%2Fz%2Fq.png"))
|
||
(list (get (host/blog-field-values-of "fpost") "subtitle")
|
||
(get (host/blog-field-values-of "fpost") "hero")))
|
||
(list "Saved Sub" "http://z/q.png"))
|
||
|
||
;; -- Slice 8c: render template per type (fields drive the page too) --
|
||
(host-bl-test "instantiate resolves (field name), replacing the placeholder"
|
||
(list (contains? (str (host/blog--instantiate (parse-safe "(p (field \"subtitle\"))") {"subtitle" "Hi"})) "Hi")
|
||
(contains? (str (host/blog--instantiate (parse-safe "(p (field \"x\"))") {})) "field"))
|
||
(list true false))
|
||
(host-bl-test "template-of reads the article's seeded render template"
|
||
(contains? (host/blog-template-of "article") "field") true)
|
||
(host-bl-test "typed-block renders a typed post's field values"
|
||
(begin
|
||
(host/blog--set-field-values! "fpost" {"subtitle" "My Standfirst" "hero" ""})
|
||
(contains? (str (host/blog--typed-block "fpost")) "My Standfirst"))
|
||
true)
|
||
(host-bl-test "typed-block is empty for an untyped post"
|
||
(host/blog--typed-block "hello") "")
|
||
(host-bl-test "post page renders the typed template standfirst"
|
||
(contains? (dream-resp-body (host-bl-app (host-bl-req "/fpost/"))) "My Standfirst") true)
|
||
|
||
;; -- metamodel editor: define a type through the UI (POST /meta/new-type) --
|
||
(host-bl-test "/meta has the create-type form"
|
||
(contains? (dream-resp-body (host-bl-app (host-bl-req "/meta"))) "/meta/new-type") true)
|
||
(host-bl-test "POST /meta/new-type creates a type (subtype-of type) in type-defs"
|
||
(begin
|
||
(host-bl-wapp (host-bl-send "POST" "/meta/new-type" "Bearer good"
|
||
"application/x-www-form-urlencoded" "title=Recipe"))
|
||
(list (host/blog-exists? "recipe") (contains? (host/blog-type-defs) "recipe")))
|
||
(list true true))
|
||
(host-bl-test "create-type requires auth (unauthed -> not created)"
|
||
(begin
|
||
(host-bl-wapp (host-bl-send "POST" "/meta/new-type" nil
|
||
"application/x-www-form-urlencoded" "title=Sneaky Type"))
|
||
(host/blog-exists? "sneaky-type"))
|
||
false)
|
||
(host-bl-test "a post with no schema'd type is vacuously valid"
|
||
(host/blog-type-valid? "ppost" "(p \"anything\")") true)
|
||
(host-bl-test "edit-submit rejects content violating the type schema (not saved)"
|
||
(begin
|
||
(host-bl-wapp (host-bl-send "POST" "/art1/edit" "Bearer good"
|
||
"application/x-www-form-urlencoded" "sx_content=(p+%22still+no+heading%22)"))
|
||
(contains? (dream-resp-body (host-bl-wapp (host-bl-req "/art1/"))) "still no heading"))
|
||
false)
|
||
(host-bl-test "edit-submit accepts content satisfying the schema -> 303"
|
||
(dream-status (host-bl-wapp (host-bl-send "POST" "/art1/edit" "Bearer good"
|
||
"application/x-www-form-urlencoded" "sx_content=(article+(h1+%22Heading%22)+(p+%22body%22))")))
|
||
303)
|
||
|
||
;; -- experimental unguarded create-only route (POST /new, no auth) --
|
||
(define host-bl-oapp (host/make-app (list host/blog-open-create-routes host/blog-routes)))
|
||
(host/blog-use-store! (persist/open))
|
||
(host-bl-test "open create no auth -> 303"
|
||
(dream-status (host-bl-oapp (host-bl-send "POST" "/new" nil
|
||
"application/x-www-form-urlencoded" "title=Open+Post&sx_content=(p+%22o%22)&status=published")))
|
||
303)
|
||
(host-bl-test "open-created post renders"
|
||
(contains? (dream-resp-body (host-bl-oapp (host-bl-req "/open-post/"))) "<p>o</p>")
|
||
true)
|
||
|
||
;; ── content-addressing: every object carries a stable CID ───────────
|
||
;; A CID is the hash of the canonical (key-sorted) content; the slug (a name) and
|
||
;; any prior :cid are excluded. Same content -> same CID, across slugs and processes.
|
||
(host/blog-use-store! (persist/open))
|
||
(host/blog-put! "cid-a" "Same Body" "(p \"same\")" "published")
|
||
(host/blog-put! "cid-b" "Same Body" "(p \"same\")" "published")
|
||
(host-bl-test "put! stamps a non-nil CID"
|
||
(and (not (nil? (host/blog-cid "cid-a"))) (> (len (host/blog-cid "cid-a")) 1)) true)
|
||
(host-bl-test "content-addressed: identical content -> identical CID (slug excluded)"
|
||
(= (host/blog-cid "cid-a") (host/blog-cid "cid-b")) true)
|
||
(host-bl-test "CID changes when content changes"
|
||
(let ((before (host/blog-cid "cid-a")))
|
||
(host/blog-put! "cid-a" "Same Body" "(p \"different now\")" "published")
|
||
(not (= before (host/blog-cid "cid-a"))))
|
||
true)
|
||
(host-bl-test "canon excludes :slug and :cid"
|
||
(= (host/blog--canon {:slug "x" :cid "old" :title "T"})
|
||
(host/blog--canon {:title "T"}))
|
||
true)
|
||
(host-bl-test "by-cid reverse lookup finds a slug with that CID"
|
||
(not (nil? (host/blog-by-cid (host/blog-cid "cid-b")))) true)
|
||
(host-bl-test "by-cid of an unknown CID is nil"
|
||
(host/blog-by-cid "znope-nope") nil)
|
||
|
||
(define
|
||
host-bl-tests-run!
|
||
(fn ()
|
||
{:total (+ host-bl-pass host-bl-fail)
|
||
:passed host-bl-pass :failed host-bl-fail :fails host-bl-fails}))
|