- example/todomvc/pocketbook/todomvc.cljs — app source - example/todomvc/todomvc.html — page - example/todomvc/js/ — compiled output (gitignored) Server static file serving is now filesystem-based via --static-dir flag instead of classpath resources. No static dir by default (API only); bb server passes --static-dir example/todomvc. Removed resources/public/ — library has no bundled static assets.
143 lines
5.5 KiB
Clojure
143 lines
5.5 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/ofByteArray))]
|
|
{: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))]
|
|
{: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))))))
|