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
186 lines
5.9 KiB
Clojure
186 lines
5.9 KiB
Clojure
(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))))
|