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