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
This commit is contained in:
185
test/pocketbook/core_test.clj
Normal file
185
test/pocketbook/core_test.clj
Normal file
@@ -0,0 +1,185 @@
|
||||
(ns pocketbook.core-test
|
||||
(:require [clojure.test :refer [deftest is testing use-fixtures]]
|
||||
[clojure.core.async :as async :refer [<!! go <! timeout]]
|
||||
[pocketbook.core :as pb]
|
||||
[pocketbook.store :as store]
|
||||
[pocketbook.store.memory :as memory]
|
||||
[pocketbook.server :as server])
|
||||
(:import [java.io File]))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Fixtures — start a real server for sync tests
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(def ^:dynamic *port* nil)
|
||||
(def ^:dynamic *server* 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-core-test" ".db"))
|
||||
srv (server/start! {:port port :db-path db-path})]
|
||||
(Thread/sleep 200)
|
||||
(try
|
||||
(binding [*server* srv *port* port]
|
||||
(f))
|
||||
(finally
|
||||
(server/stop! srv)
|
||||
(.delete (File. db-path)))))))
|
||||
|
||||
(defn- server-url []
|
||||
(str "http://localhost:" *port* "/sync"))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Helpers
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defn- <!!timeout
|
||||
"Take from channel with timeout. Returns nil on timeout."
|
||||
[ch ms]
|
||||
(let [[v _] (<!! (go (async/alts! [ch (timeout ms)])))]
|
||||
v))
|
||||
|
||||
(defn- wait-synced
|
||||
"Wait until the synced atom has no pending changes."
|
||||
[sa ms]
|
||||
(let [deadline (+ (System/currentTimeMillis) ms)]
|
||||
(loop []
|
||||
(when (and (pos? (pb/pending-count sa))
|
||||
(< (System/currentTimeMillis) deadline))
|
||||
(Thread/sleep 50)
|
||||
(recur)))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Tests
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(deftest synced-atom-local-only
|
||||
(testing "SyncedAtom works without a server (local store only)"
|
||||
(let [store (memory/create)
|
||||
sa (pb/synced-atom store "todo")]
|
||||
(<!!timeout (pb/ready? sa) 1000)
|
||||
(is (= {} @sa))
|
||||
|
||||
(swap! sa assoc "todo:1" {:text "Buy milk"})
|
||||
(is (= {:text "Buy milk"} (get @sa "todo:1")))
|
||||
|
||||
(swap! sa assoc "todo:2" {:text "Walk dog"})
|
||||
(is (= 2 (count @sa)))
|
||||
|
||||
(swap! sa dissoc "todo:1")
|
||||
(is (= 1 (count @sa)))
|
||||
(is (nil? (get @sa "todo:1")))
|
||||
|
||||
(pb/destroy! sa))))
|
||||
|
||||
(deftest synced-atom-persists-to-store
|
||||
(testing "Changes are persisted to the store"
|
||||
(let [store (memory/create)
|
||||
sa (pb/synced-atom store "todo")]
|
||||
(<!!timeout (pb/ready? sa) 1000)
|
||||
|
||||
(swap! sa assoc "todo:1" {:text "Buy milk"})
|
||||
(Thread/sleep 50) ;; let async store write complete
|
||||
|
||||
;; Read from store directly
|
||||
(let [docs (<!!timeout (store/docs-by-prefix store "todo:") 1000)]
|
||||
(is (= 1 (count docs)))
|
||||
(is (= "todo:1" (:id (first docs))))
|
||||
(is (= {:text "Buy milk"} (:value (first docs)))))
|
||||
|
||||
(pb/destroy! sa))))
|
||||
|
||||
(deftest synced-atom-loads-from-store
|
||||
(testing "SyncedAtom loads existing data from store on creation"
|
||||
(let [store (memory/create)]
|
||||
;; Pre-populate the store
|
||||
(<!!timeout (store/put-doc! store
|
||||
{:id "todo:1" :value {:text "Existing"}
|
||||
:version 1 :updated 1000 :deleted false :synced true})
|
||||
1000)
|
||||
|
||||
(let [sa (pb/synced-atom store "todo")]
|
||||
(<!!timeout (pb/ready? sa) 1000)
|
||||
(is (= {:text "Existing"} (get @sa "todo:1")))
|
||||
(pb/destroy! sa)))))
|
||||
|
||||
(deftest synced-atom-watches
|
||||
(testing "add-watch fires on changes"
|
||||
(let [store (memory/create)
|
||||
sa (pb/synced-atom store "todo")
|
||||
changes (atom [])]
|
||||
(<!!timeout (pb/ready? sa) 1000)
|
||||
|
||||
(add-watch sa :test (fn [_ _ old new]
|
||||
(swap! changes conj {:old old :new new})))
|
||||
|
||||
(swap! sa assoc "todo:1" {:text "Hello"})
|
||||
(Thread/sleep 50)
|
||||
|
||||
(is (= 1 (count @changes)))
|
||||
(is (= {} (:old (first @changes))))
|
||||
(is (= {"todo:1" {:text "Hello"}} (:new (first @changes))))
|
||||
|
||||
(remove-watch sa :test)
|
||||
(pb/destroy! sa))))
|
||||
|
||||
(deftest synced-atom-push-to-server
|
||||
(testing "Local changes are pushed to the server"
|
||||
(let [store (memory/create)
|
||||
sa (pb/synced-atom store "todo"
|
||||
{:server (server-url)})]
|
||||
(<!!timeout (pb/ready? sa) 2000)
|
||||
|
||||
(swap! sa assoc "todo:push1" {:text "Pushed!"})
|
||||
(Thread/sleep 500) ;; let push complete
|
||||
|
||||
(is (zero? (pb/pending-count sa)))
|
||||
(pb/destroy! sa))))
|
||||
|
||||
(deftest synced-atom-pull-from-server
|
||||
(testing "Two clients sync via server"
|
||||
(let [store-a (memory/create)
|
||||
store-b (memory/create)
|
||||
sa-a (pb/synced-atom store-a "todo"
|
||||
{:server (server-url) :interval 500})
|
||||
sa-b (pb/synced-atom store-b "todo"
|
||||
{:server (server-url) :interval 500})]
|
||||
(<!!timeout (pb/ready? sa-a) 2000)
|
||||
(<!!timeout (pb/ready? sa-b) 2000)
|
||||
|
||||
;; Client A writes
|
||||
(swap! sa-a assoc "todo:sync1" {:text "From A"})
|
||||
(Thread/sleep 500) ;; let A push
|
||||
|
||||
;; Trigger a sync on B
|
||||
(<!!timeout (pb/sync-now! sa-b) 2000)
|
||||
|
||||
;; B should have A's data
|
||||
(is (= {:text "From A"} (get @sa-b "todo:sync1")))
|
||||
|
||||
(pb/destroy! sa-a)
|
||||
(pb/destroy! sa-b))))
|
||||
|
||||
(deftest synced-atom-deref-swap-reset
|
||||
(testing "Standard atom operations work"
|
||||
(let [store (memory/create)
|
||||
sa (pb/synced-atom store "note")]
|
||||
(<!!timeout (pb/ready? sa) 1000)
|
||||
|
||||
;; reset!
|
||||
(reset! sa {"note:1" {:body "Hello"}})
|
||||
(is (= {"note:1" {:body "Hello"}} @sa))
|
||||
|
||||
;; swap! with multiple args
|
||||
(swap! sa assoc "note:2" {:body "World"})
|
||||
(is (= 2 (count @sa)))
|
||||
|
||||
(swap! sa update "note:1" assoc :edited true)
|
||||
(is (true? (:edited (get @sa "note:1"))))
|
||||
|
||||
(pb/destroy! sa))))
|
||||
@@ -41,19 +41,19 @@
|
||||
(.header "Accept" "application/transit+json")
|
||||
(.GET)
|
||||
(.build))
|
||||
resp (.send client req (HttpResponse$BodyHandlers/ofByteArray))]
|
||||
resp (.send client req (HttpResponse$BodyHandlers/ofString))]
|
||||
{: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))]
|
||||
(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))}))
|
||||
|
||||
|
||||
@@ -30,8 +30,8 @@
|
||||
:version 5}]]
|
||||
(is (= data (t/decode (t/encode data))))))
|
||||
|
||||
(deftest encode-str-roundtrip
|
||||
(deftest encode-returns-string
|
||||
(let [v {:hello "world" :nums [1 2 3]}
|
||||
s (t/encode-str v)]
|
||||
s (t/encode v)]
|
||||
(is (string? s))
|
||||
(is (= v (t/decode s)))))
|
||||
|
||||
Reference in New Issue
Block a user