Move core, sync, and transit from platform-specific .clj/.cljs to shared .cljc files with reader conditionals. This enables testing the full sync logic on the JVM and using SyncedAtom from Clojure clients. Key changes: - PStore protocol (store.cljc) decouples core from storage backend - IDB store (store/idb.cljs) and memory store (store/memory.cljc) - SyncedAtom implements CLJ IDeref/IAtom/IRef + CLJS equivalents - Sync client uses java.net.http on CLJ, fetch on CLJS - SSE remains CLJS-only; JVM clients use polling - API change: store passed explicitly instead of pb/open - 7 new JVM tests: local ops, persistence, watches, two-client sync - 28 tests total, 87 assertions, all passing
180 lines
7.3 KiB
Clojure
180 lines
7.3 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/ofString))]
|
|
{:status (.statusCode resp)
|
|
:body (t/decode (.body resp))}))
|
|
|
|
(defn- post-transit [path body]
|
|
(let [encoded (t/encode body)
|
|
req (-> (HttpRequest/newBuilder)
|
|
(.uri (URI. (url path)))
|
|
(.header "Content-Type" "application/transit+json")
|
|
(.header "Accept" "application/transit+json")
|
|
(.POST (HttpRequest$BodyPublishers/ofString encoded))
|
|
(.build))
|
|
resp (.send client req (HttpResponse$BodyHandlers/ofString))]
|
|
{:status (.statusCode resp)
|
|
:body (t/decode (.body resp))}))
|
|
|
|
;; ---------------------------------------------------------------------------
|
|
;; Tests
|
|
;; ---------------------------------------------------------------------------
|
|
|
|
(deftest root-returns-404-without-static-dir
|
|
(let [req (-> (HttpRequest/newBuilder)
|
|
(.uri (URI. (url "/")))
|
|
(.GET)
|
|
(.build))
|
|
resp (.send client req (HttpResponse$BodyHandlers/ofString))]
|
|
(is (= 404 (.statusCode resp)))))
|
|
|
|
(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))))))
|
|
|
|
(deftest sse-notifies-on-push
|
|
(testing "SSE endpoint sends event when a push succeeds"
|
|
(let [;; Open SSE connection
|
|
sse-req (-> (HttpRequest/newBuilder)
|
|
(.uri (URI. (url "/events" "group=todo")))
|
|
(.GET)
|
|
(.build))
|
|
;; Use a short timeout — we'll read what we get
|
|
events (atom [])
|
|
future (java.util.concurrent.CompletableFuture/supplyAsync
|
|
(reify java.util.function.Supplier
|
|
(get [_]
|
|
(try
|
|
(let [resp (.send client sse-req
|
|
(HttpResponse$BodyHandlers/ofInputStream))
|
|
is (.body resp)
|
|
rdr (java.io.BufferedReader.
|
|
(java.io.InputStreamReader. is "UTF-8"))]
|
|
;; Read lines until we get a data event about "todo"
|
|
(loop [deadline (+ (System/currentTimeMillis) 3000)]
|
|
(when (< (System/currentTimeMillis) deadline)
|
|
(when-let [line (.readLine rdr)]
|
|
(swap! events conj line)
|
|
(if (= line "data: todo")
|
|
@events
|
|
(recur deadline))))))
|
|
(catch Exception _ @events)))))]
|
|
;; Give SSE time to connect
|
|
(Thread/sleep 200)
|
|
;; Push a doc — should trigger SSE event
|
|
(post-transit "/sync"
|
|
[{:id "todo:sse-test" :value {:text "SSE!"} :base-version 0}])
|
|
;; Wait for SSE reader to receive it
|
|
(let [result (.get future 4 java.util.concurrent.TimeUnit/SECONDS)]
|
|
(is (some #(= "data: todo" %) result)
|
|
"SSE should have received a 'data: todo' event")))))
|