Files
atomsync/test/pocketbook/core_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

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