(ns pocketbook.core-test (:require [clojure.test :refer [deftest is testing use-fixtures]] [promesa.core :as p] [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- await! "Deref a promise with timeout. Returns nil on timeout." [promise ms] (deref promise ms nil)) (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")] (await! (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")] (await! (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 (await! (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 (await! (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")] (await! (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 [])] (await! (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)})] (await! (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})] (await! (pb/ready? sa-a) 2000) (await! (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 (await! (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")] (await! (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))))