Files
atomsync/test/pocketbook/server_test.clj
Florian Schroedl ab68a21dd6 feat: add TodoMVC example with editorial design
Full TodoMVC implementation using Pocketbook's synced atom:
- Add, toggle, edit (double-click), destroy todos
- Filter: All / Active / Completed
- Toggle all, clear completed
- Live sync status indicator (online/offline/pending)
- Data persists in IndexedDB, syncs to server via Transit

Design: Ink & Paper aesthetic — cream paper background with grain
texture, Instrument Serif italic header, Newsreader body text,
DM Mono UI chrome, terracotta accent with green completion marks,
deckled left border on card.

Also:
- Server now serves static files from resources/public (/ → todomvc.html)
- Fix CLJS compilation: resolve close!/put! naming conflicts with
  core.async, use qualified async/close!, add clojure.string require
- Fix unbalanced parens in do-pull!
- Remove old placeholder example
2026-04-04 16:45:14 +02:00

144 lines
5.5 KiB
Clojure

(ns pocketbook.server-test
(:require [clojure.test :refer [deftest is testing use-fixtures]]
[pocketbook.server :as server]
[pocketbook.transit :as t])
(:import [java.io File]
[java.net URI]
[java.net.http HttpClient HttpRequest HttpResponse$BodyHandlers HttpRequest$BodyPublishers]))
(def ^:dynamic *server* nil)
(def ^:dynamic *port* nil)
(defn- free-port []
(with-open [s (java.net.ServerSocket. 0)]
(.getLocalPort s)))
(use-fixtures :each
(fn [f]
(let [port (free-port)
db-path (str (File/createTempFile "pocketbook-server-test" ".db"))
srv (server/start! {:port port :db-path db-path})]
(Thread/sleep 200) ;; let server start
(try
(binding [*server* srv *port* port]
(f))
(finally
(server/stop! srv)
(.delete (File. db-path)))))))
;; ---------------------------------------------------------------------------
;; HTTP helpers
;; ---------------------------------------------------------------------------
(def ^:private client (HttpClient/newHttpClient))
(defn- url [path & [query]]
(str "http://localhost:" *port* path (when query (str "?" query))))
(defn- get-transit [path query]
(let [req (-> (HttpRequest/newBuilder)
(.uri (URI. (url path query)))
(.header "Accept" "application/transit+json")
(.GET)
(.build))
resp (.send client req (HttpResponse$BodyHandlers/ofByteArray))]
{:status (.statusCode resp)
:body (t/decode (.body resp))}))
(defn- post-transit [path body]
(let [bytes (t/encode body)
req (-> (HttpRequest/newBuilder)
(.uri (URI. (url path)))
(.header "Content-Type" "application/transit+json")
(.header "Accept" "application/transit+json")
(.POST (HttpRequest$BodyPublishers/ofByteArray bytes))
(.build))
resp (.send client req (HttpResponse$BodyHandlers/ofByteArray))]
{:status (.statusCode resp)
:body (t/decode (.body resp))}))
;; ---------------------------------------------------------------------------
;; Tests
;; ---------------------------------------------------------------------------
(deftest serves-static-root
(let [req (-> (HttpRequest/newBuilder)
(.uri (URI. (url "/")))
(.GET)
(.build))
resp (.send client req (HttpResponse$BodyHandlers/ofString))]
(is (= 200 (.statusCode resp)))
(is (.contains (.body resp) "TodoMVC"))))
(deftest push-and-pull
(testing "Push new documents"
(let [resp (post-transit "/sync"
[{:id "todo:1" :value {:text "Buy milk" :tags #{:groceries}} :base-version 0}
{:id "todo:2" :value {:text "Buy eggs"} :base-version 0}])]
(is (= 200 (:status resp)))
(is (every? #(= :ok (:status %)) (:body resp)))
(is (= 1 (:version (first (:body resp)))))))
(testing "Pull all docs"
(let [resp (get-transit "/sync" "group=todo&since=0")]
(is (= 200 (:status resp)))
(is (= 2 (count (:body resp))))
(is (= #{:groceries} (:tags (:value (first (:body resp))))))))
(testing "Update a doc"
(let [resp (post-transit "/sync"
[{:id "todo:1" :value {:text "Buy oat milk"} :base-version 1}])]
(is (= :ok (:status (first (:body resp)))))
(is (= 2 (:version (first (:body resp)))))))
(testing "Pull only recent changes"
(let [all (get-transit "/sync" "group=todo&since=0")
ts (:updated (second (:body all)))
recent (get-transit "/sync" (str "group=todo&since=" ts))]
;; Should get only todo:1 (updated) but not todo:2 (unchanged since ts)
;; (depends on timing, but at minimum we get at least 1)
(is (<= (count (:body recent)) 2)))))
(deftest push-conflict
(post-transit "/sync"
[{:id "todo:1" :value {:text "v1"} :base-version 0}])
(let [resp (post-transit "/sync"
[{:id "todo:1" :value {:text "stale"} :base-version 0}])]
(is (= :conflict (:status (first (:body resp)))))
(is (= 1 (:current-version (first (:body resp)))))))
(deftest push-delete
(post-transit "/sync"
[{:id "todo:del" :value {:text "delete me"} :base-version 0}])
(let [resp (post-transit "/sync"
[{:id "todo:del" :deleted true :base-version 1}])]
(is (= :ok (:status (first (:body resp))))))
(let [resp (get-transit "/sync" "group=todo&since=0")]
(is (some #(and (= "todo:del" (:id %)) (:deleted %)) (:body resp)))))
(deftest missing-group-param
(let [resp (get-transit "/sync" "since=0")]
(is (= 400 (:status resp)))))
(deftest type-preservation-over-wire
(let [uuid (java.util.UUID/randomUUID)
inst #inst "2026-04-04T10:00:00Z"
value {:keyword :hello
:set #{1 2 3}
:vec [1 "two" :three]
:uuid uuid
:inst inst
:nested {:a {:b 42}}}]
(post-transit "/sync"
[{:id "types:1" :value value :base-version 0}])
(let [resp (get-transit "/sync" "group=types&since=0")
pulled (:value (first (:body resp)))]
;; Transit preserves most types but nippy is used server-side
;; The round-trip is: transit-decode → nippy-freeze → nippy-thaw → transit-encode
(is (= :hello (:keyword pulled)))
(is (= #{1 2 3} (:set pulled)))
(is (= [1 "two" :three] (:vec pulled)))
(is (= uuid (:uuid pulled)))
(is (= inst (:inst pulled)))
(is (= {:a {:b 42}} (:nested pulled))))))