Files
atomsync/test/pocketbook/server_test.clj
Florian Schroedl 86b54e1291 refactor: extract shared .cljc library with store protocol
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
2026-04-16 19:42:06 +02:00

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")))))