summaryrefslogtreecommitdiff
path: root/tests
diff options
context:
space:
mode:
authorEuAndreh <eu@euandre.org>2026-04-21 22:16:16 -0300
committerEuAndreh <eu@euandre.org>2026-04-21 22:16:16 -0300
commit64dbf930f11a1d1a70f970627e23ab82ab188528 (patch)
treed74a1de15d9c8245b33557403034f40eb3023154 /tests
parentm (diff)
downloadpapod-64dbf930f11a1d1a70f970627e23ab82ab188528.tar.gz
papod-64dbf930f11a1d1a70f970627e23ab82ab188528.tar.xz
m
Diffstat (limited to 'tests')
-rw-r--r--tests/unit.clj772
1 files changed, 770 insertions, 2 deletions
diff --git a/tests/unit.clj b/tests/unit.clj
index e92b389..af13931 100644
--- a/tests/unit.clj
+++ b/tests/unit.clj
@@ -1,5 +1,9 @@
(ns unit
(:require [clojure.test :as t :refer [are deftest is testing]]
+ [clojure.string :as string]
+ [cracha]
+ [datomic.api :as d]
+ [fiinha]
[papod])
(:gen-class))
@@ -38,10 +42,774 @@
[{:prefix nil
:command "CMD"
:params []}
- nil]))))
+ nil])))
+ (testing "prefix with nick only"
+ (is (= (parse-message ":nick CMD")
+ [{:prefix {:nick "nick" :user nil :host nil}
+ :command "CMD"
+ :params []}
+ nil])))
+ (testing "prefix with nick and user"
+ (is (= (parse-message ":nick!user CMD")
+ [{:prefix {:nick "nick" :user "user" :host nil}
+ :command "CMD"
+ :params []}
+ nil])))
+ (testing "prefix with nick and host"
+ (is (= (parse-message ":nick@host CMD")
+ [{:prefix {:nick "nick" :user nil :host "host"}
+ :command "CMD"
+ :params []}
+ nil])))
+ (testing "prefix with nick, user and host"
+ (is (= (parse-message ":nick!user@host CMD")
+ [{:prefix {:nick "nick" :user "user" :host "host"}
+ :command "CMD"
+ :params []}
+ nil])))
+ (testing "prefix errors from parse-prefix"
+ (is (= :bad-empty-prefix-and-message
+ (-> (parse-message ":") second :type)))
+ (is (= :bad-empty-prefix
+ (-> (parse-message ": CMD") second :type)))
+ (is (= :no-prefix-separator
+ (-> (parse-message ":nospaceatall") second :type))))
+ (testing "prefix errors from parse-prefix-components"
+ (is (= :bad-prefix-format
+ (-> (parse-message ":a@b!c CMD") second :type)))
+ (is (= :bad-empty-nick
+ (-> (parse-message ":@host CMD") second :type)))
+ (is (= :bad-empty-user
+ (-> (parse-message ":nick!@host CMD") second :type)))
+ (is (= :bad-empty-host
+ (-> (parse-message ":nick!user@ CMD") second :type)))
+ (is (= :bad-empty-nick
+ (-> (parse-message ":!user CMD") second :type)))))
+
+(def replies-for! @#'papod/replies-for!)
+(def clean-params @#'papod/clean-params)
+(defn client [] (atom {:nick nil :user nil :pass nil :registered? false}))
+(def no-conn {})
+
+(deftest test_clean-params
+ (testing "removes leading empty strings"
+ (is (= (clean-params ["" "a" "b"]) ["a" "b"]))
+ (is (= (clean-params ["a" "b"]) ["a" "b"]))
+ (is (= (clean-params []) []))))
+
+(deftest test_replies-for!
+ (testing "PASS stores password"
+ (let [c (client)]
+ (is (= (replies-for! {:command "PASS" :params ["" "secret"]}
+ c no-conn)
+ []))
+ (is (= (:pass @c) "secret"))))
+ (testing "PASS with no params"
+ (let [c (client)]
+ (is (string/includes?
+ (first (replies-for! {:command "PASS" :params []}
+ c no-conn))
+ "461"))))
+ (testing "NICK stores nickname"
+ (let [c (client)]
+ (is (= (replies-for! {:command "NICK" :params ["" "joe"]}
+ c no-conn)
+ []))
+ (is (= (:nick @c) "joe"))))
+ (testing "NICK with no params"
+ (let [c (client)]
+ (is (string/includes?
+ (first (replies-for! {:command "NICK" :params []}
+ c no-conn))
+ "431"))))
+ (testing "registration completes after NICK and USER"
+ (let [c (client)]
+ (replies-for! {:command "NICK" :params ["" "joe"]} c no-conn)
+ (let [replies (replies-for!
+ {:command "USER"
+ :params ["" "joe" "0" "x" ":Joe"]}
+ c no-conn)]
+ (is (= 1 (count replies)))
+ (is (string/includes? (first replies) "001"))
+ (is (:registered? @c)))))
+ (testing "USER before NICK does not register"
+ (let [c (client)]
+ (let [replies (replies-for!
+ {:command "USER"
+ :params ["" "joe" "0" "x" ":Joe"]}
+ c no-conn)]
+ (is (empty? replies))
+ (is (not (:registered? @c))))))
+ (testing "USER after registration"
+ (let [c (client)]
+ (replies-for! {:command "NICK" :params ["" "joe"]} c no-conn)
+ (replies-for! {:command "USER" :params ["" "joe" "0" "x" ":Joe"]}
+ c no-conn)
+ (is (string/includes?
+ (first (replies-for!
+ {:command "USER" :params ["" "joe" "0" "x" ":Joe"]}
+ c no-conn))
+ "462"))))
+ (testing "PASS after registration"
+ (let [c (client)]
+ (replies-for! {:command "NICK" :params ["" "joe"]} c no-conn)
+ (replies-for! {:command "USER" :params ["" "joe" "0" "x" ":Joe"]}
+ c no-conn)
+ (is (string/includes?
+ (first (replies-for! {:command "PASS" :params ["" "pw"]}
+ c no-conn))
+ "462"))))
+ (testing "unregistered client gets 451 for other commands"
+ (let [c (client)]
+ (is (string/includes?
+ (first (replies-for! {:command "JOIN" :params ["" "#chan"]}
+ c no-conn))
+ "451"))))
+ (testing "PING responds with PONG"
+ (let [c (client)]
+ (let [replies (replies-for!
+ {:command "PING" :params ["" "token"]}
+ c no-conn)]
+ (is (= 1 (count replies)))
+ (is (string/includes? (first replies) "PONG"))
+ (is (string/includes? (first replies) "token")))))
+ (testing "PING with no params"
+ (let [c (client)]
+ (is (string/includes?
+ (first (replies-for! {:command "PING" :params []}
+ c no-conn))
+ "409")))))
+
+(defn test-components
+ []
+ (let [fiinha-state (fiinha/initdb!
+ (str "datomic:mem://fiinha-" (java.util.UUID/randomUUID)))
+ cracha-state (cracha/init!
+ (str "datomic:mem://cracha-" (java.util.UUID/randomUUID))
+ fiinha-state)
+ papod-uri (str "datomic:mem://papod-" (java.util.UUID/randomUUID))
+ _ (d/create-database papod-uri)
+ conn (d/connect papod-uri)]
+ @(d/transact conn @#'papod/schema)
+ {:conn conn :cracha cracha-state
+ :clients (atom {}) :channels (atom {})}))
+
+(defn test-network!
+ [conn]
+ (let [net-id (java.util.UUID/randomUUID)]
+ @(d/transact conn
+ [{:db/ensure :papod.network/attrs
+ :papod.network/id net-id
+ :papod.network/name (str "test-" net-id)
+ :papod.network/description ""
+ :papod.network/type "public"
+ :papod.network/created-at (java.util.Date.)}])
+ net-id))
+
+(defn test-components-with-network
+ []
+ (let [components (test-components)
+ net-id (test-network! (:conn components))]
+ (assoc components :test-network-id net-id)))
+
+(defn registered-client
+ ([nick w]
+ (atom {:nick nick :user {:username nick} :pass nil :registered? true :w w}))
+ ([nick w net-id]
+ (atom {:nick nick :user {:username nick} :pass nil :registered? true
+ :w w :network-id net-id})))
+
+(def handle-privmsg @#'papod/handle-privmsg)
+(def handle-join @#'papod/handle-join)
+(def resolve-channel @#'papod/resolve-channel)
+(def handle-cap @#'papod/handle-cap)
+(def handle-authenticate @#'papod/handle-authenticate)
+
+(deftest test_persist-first
+ (testing "DM persists before delivery"
+ (let [sender-out (java.io.ByteArrayOutputStream.)
+ target-out (java.io.ByteArrayOutputStream.)
+ sender (registered-client "alice" sender-out)
+ components (assoc (test-components)
+ :clients (atom {"alice" {:w sender-out}
+ "bob" {:w target-out}})
+ :channels (atom {}))
+ conn (:conn components)]
+ (handle-privmsg ["bob" ":hello world"] sender components)
+ ;; Verify event persisted with target-nick
+ (let [db (d/db conn)
+ events (d/q '{:find [?nick ?target ?payload]
+ :where [[?e :papod.event/source-nick ?nick]
+ [?e :papod.event/target-nick ?target]
+ [?e :papod.event/payload ?payload]]}
+ db)]
+ (is (= 1 (count events)))
+ (is (= ["alice" "bob" ":hello world"]
+ (vec (first events)))))
+ ;; Delivered to target, not sender
+ (is (string/includes? (.toString target-out "UTF-8")
+ "PRIVMSG bob :hello world"))
+ (is (= "" (.toString sender-out "UTF-8")))))
+ (testing "channel PRIVMSG persists with channel ref"
+ (let [alice-out (java.io.ByteArrayOutputStream.)
+ bob-out (java.io.ByteArrayOutputStream.)
+ {:keys [test-network-id] :as components}
+ (assoc (test-components-with-network)
+ :clients (atom {"alice" {:w alice-out}
+ "bob" {:w bob-out}})
+ :channels (atom {"#test" #{"alice" "bob"}}))
+ sender (registered-client "alice" alice-out test-network-id)
+ conn (:conn components)]
+ ;; Create the channel in DB first (JOIN would do this normally)
+ @(d/transact conn
+ [{:papod.channel/id (java.util.UUID/randomUUID)
+ :papod.channel/network [:papod.network/id test-network-id]
+ :papod.channel/name "#test"
+ :papod.channel/type "public"
+ :papod.channel/description ""
+ :papod.channel/created-at (java.util.Date.)}])
+ (handle-privmsg ["#test" ":hi everyone"] sender components)
+ ;; Verify event references the channel entity
+ (let [db (d/db conn)
+ events (d/q '{:find [?type ?payload ?chan-name]
+ :where [[?e :papod.event/channel ?c]
+ [?e :papod.event/type ?type]
+ [?e :papod.event/payload ?payload]
+ [?c :papod.channel/name ?chan-name]]}
+ db)]
+ (is (= 1 (count events)))
+ (is (= ["user-message" ":hi everyone" "#test"]
+ (vec (first events)))))
+ ;; Delivered to bob, not alice
+ (is (string/includes? (.toString bob-out "UTF-8")
+ "PRIVMSG #test :hi everyone"))
+ (is (= "" (.toString alice-out "UTF-8")))))
+ (testing "JOIN creates channel + membership + event atomically"
+ (let [alice-out (java.io.ByteArrayOutputStream.)
+ {:keys [test-network-id] :as components}
+ (assoc (test-components-with-network)
+ :clients (atom {"alice" {:w alice-out}})
+ :channels (atom {}))
+ sender (registered-client "alice" alice-out test-network-id)
+ conn (:conn components)]
+ (handle-join ["#test"] sender components)
+ (let [db (d/db conn)]
+ ;; Channel entity created
+ (is (some? (resolve-channel db "#test")))
+ ;; Membership recorded
+ (let [memberships (d/q '{:find [?nick ?chan-name]
+ :where [[?m :papod.membership/nick ?nick]
+ [?m :papod.membership/channel ?c]
+ [?c :papod.channel/name ?chan-name]]}
+ db)]
+ (is (= #{["alice" "#test"]} memberships)))
+ ;; Event recorded
+ (let [events (d/q '{:find [?type ?nick ?chan-name]
+ :where [[?e :papod.event/type ?type]
+ [?e :papod.event/source-nick ?nick]
+ [?e :papod.event/channel ?c]
+ [?c :papod.channel/name ?chan-name]]}
+ db)]
+ (is (= #{["user-join" "alice" "#test"]} events))))
+ ;; In-memory updated
+ (is (= #{"alice"} (get @(:channels components) "#test")))
+ ;; Notification delivered
+ (is (string/includes? (.toString alice-out "UTF-8") "JOIN #test"))))
+ (testing "second JOIN reuses existing channel"
+ (let [alice-out (java.io.ByteArrayOutputStream.)
+ bob-out (java.io.ByteArrayOutputStream.)
+ {:keys [test-network-id] :as components}
+ (assoc (test-components-with-network)
+ :clients (atom {"alice" {:w alice-out}
+ "bob" {:w bob-out}})
+ :channels (atom {}))
+ conn (:conn components)]
+ (handle-join ["#test"] (registered-client "alice" alice-out test-network-id) components)
+ (handle-join ["#test"] (registered-client "bob" bob-out test-network-id) components)
+ ;; Still one channel entity
+ (let [db (d/db conn)
+ channels (d/q '{:find [?name]
+ :where [[?c :papod.channel/name ?name]]}
+ db)]
+ (is (= 1 (count channels))))
+ ;; Two memberships
+ (let [db (d/db conn)
+ memberships (d/q '{:find [?nick]
+ :where [[?m :papod.membership/nick ?nick]]}
+ db)]
+ (is (= #{["alice"] ["bob"]} memberships)))))
+ (testing "PRIVMSG error cases"
+ (let [c (registered-client "alice" (java.io.ByteArrayOutputStream.))]
+ (is (string/includes?
+ (first (handle-privmsg [] c no-conn))
+ "411"))
+ (is (string/includes?
+ (first (handle-privmsg ["bob"] c no-conn))
+ "412")))))
+
+(deftest test_nickserv
+ (testing "REGISTER creates user in cracha"
+ (let [out (java.io.ByteArrayOutputStream.)
+ c (registered-client "alice" out)
+ components (test-components)
+ conn (:conn components)
+ replies (handle-privmsg ["NickServ" ":REGISTER mypass"] c components)]
+ (is (= 1 (count replies)))
+ (is (string/includes? (first replies) "registered successfully"))
+ (let [cc (:conn (:cracha components))
+ user-id (cracha/user-by-email cc "alice")]
+ (is (some? user-id))
+ (is (cracha/user-confirmed? cc user-id)))))
+ (testing "REGISTER rejects duplicate nick"
+ (let [out (java.io.ByteArrayOutputStream.)
+ c (registered-client "alice" out)
+ components (test-components)]
+ (handle-privmsg ["NickServ" ":REGISTER pass1"] c components)
+ (let [replies (handle-privmsg ["NickServ" ":REGISTER pass2"] c components)]
+ (is (string/includes? (first replies) "already registered")))))
+ (testing "IDENTIFY succeeds with correct password"
+ (let [out (java.io.ByteArrayOutputStream.)
+ c (registered-client "alice" out)
+ components (test-components)]
+ (handle-privmsg ["NickServ" ":REGISTER mypass"] c components)
+ (let [replies (handle-privmsg ["NickServ" ":IDENTIFY mypass"] c components)]
+ (is (string/includes? (first replies) "now identified"))
+ (is (:identified? @c)))))
+ (testing "IDENTIFY fails with wrong password"
+ (let [out (java.io.ByteArrayOutputStream.)
+ c (registered-client "alice" out)
+ components (test-components)]
+ (handle-privmsg ["NickServ" ":REGISTER mypass"] c components)
+ (let [replies (handle-privmsg ["NickServ" ":IDENTIFY wrong"] c components)]
+ (is (string/includes? (first replies) "Invalid password"))
+ (is (not (:identified? @c))))))
+ (testing "IDENTIFY fails for unregistered nick"
+ (let [out (java.io.ByteArrayOutputStream.)
+ c (registered-client "alice" out)
+ components (test-components)
+ replies (handle-privmsg ["NickServ" ":IDENTIFY mypass"] c components)]
+ (is (string/includes? (first replies) "not registered"))))
+ (testing "NickServ messages are NOT persisted"
+ (let [out (java.io.ByteArrayOutputStream.)
+ c (registered-client "alice" out)
+ components (test-components)
+ conn (:conn components)]
+ (handle-privmsg ["NickServ" ":REGISTER mypass"] c components)
+ (let [db (d/db conn)
+ evts (d/q '{:find [?e]
+ :where [[?e :papod.event/id _]]}
+ db)]
+ (is (zero? (count evts)))))))
+
+(defn b64
+ [s]
+ (.encodeToString (java.util.Base64/getEncoder) (.getBytes s "UTF-8")))
+
+(deftest test_sasl
+ (testing "full CAP + SASL PLAIN flow"
+ (let [out (java.io.ByteArrayOutputStream.)
+ c (client)
+ components (test-components)]
+ ;; Register user in cracha first
+ (swap! c assoc :nick "alice" :registered? true :w out)
+ (handle-privmsg ["NickServ" ":REGISTER mypass"] c components)
+ ;; Reset client for fresh connection
+ (let [c2 (atom {:nick nil :user nil :pass nil :registered? false
+ :w out})]
+ ;; CAP LS suspends registration
+ (let [replies (handle-cap ["LS" "302"] c2 components)]
+ (is (= 1 (count replies)))
+ (is (string/includes? (first replies) "sasl=PLAIN"))
+ (is (:cap-negotiating? @c2)))
+ ;; CAP REQ sasl
+ (let [replies (handle-cap ["REQ" ":sasl"] c2 components)]
+ (is (string/includes? (first replies) "ACK"))
+ (is (contains? (:caps @c2) "sasl")))
+ ;; NICK + USER during negotiation do not trigger registration
+ (swap! c2 assoc :nick "alice")
+ (let [replies (replies-for! {:command "USER" :params ["" "alice" "0" "x" ":A"]}
+ c2 components)]
+ (is (empty? replies))
+ (is (not (:registered? @c2))))
+ ;; AUTHENTICATE PLAIN
+ (let [replies (handle-authenticate ["PLAIN"] c2 components)]
+ (is (= ["AUTHENTICATE +"] replies)))
+ ;; Send credentials
+ (let [creds (b64 "\u0000alice\u0000mypass")
+ replies (handle-authenticate [creds] c2 components)]
+ (is (= 2 (count replies)))
+ (is (string/includes? (first replies) "900"))
+ (is (string/includes? (second replies) "903"))
+ (is (:authenticated? @c2)))
+ ;; CAP END completes registration
+ (let [replies (handle-cap ["END"] c2 components)]
+ (is (= 1 (count replies)))
+ (is (string/includes? (first replies) "001"))
+ (is (:registered? @c2))))))
+ (testing "SASL with wrong password"
+ (let [out (java.io.ByteArrayOutputStream.)
+ c (client)
+ components (test-components)]
+ ;; Register user
+ (swap! c assoc :nick "bob" :registered? true :w out)
+ (handle-privmsg ["NickServ" ":REGISTER secret"] c components)
+ ;; Fresh connection
+ (let [c2 (atom {:nick "bob" :user nil :pass nil :registered? false
+ :w out :caps #{"sasl"}})]
+ (handle-authenticate ["PLAIN"] c2 components)
+ (let [creds (b64 "\u0000bob\u0000wrong")
+ replies (handle-authenticate [creds] c2 components)]
+ (is (= 1 (count replies)))
+ (is (string/includes? (first replies) "904"))
+ (is (not (:authenticated? @c2)))))))
+ (testing "AUTHENTICATE without CAP sasl"
+ (let [c (atom {:nick "x" :caps #{}})]
+ (is (string/includes?
+ (first (handle-authenticate ["PLAIN"] c no-conn))
+ "904"))))
+ (testing "AUTHENTICATE abort"
+ (let [c (atom {:nick "x" :caps #{"sasl"} :sasl-state :authenticating})]
+ (is (string/includes?
+ (first (handle-authenticate ["*"] c no-conn))
+ "906"))))
+ (testing "AUTHENTICATE unsupported mechanism"
+ (let [c (atom {:nick "x" :caps #{"sasl"}})]
+ (let [replies (handle-authenticate ["SCRAM-SHA-1"] c no-conn)]
+ (is (string/includes? (first replies) "908"))
+ (is (string/includes? (second replies) "904")))))
+ (testing "CAP REQ unsupported capability"
+ (let [c (atom {:nick "x" :cap-negotiating? true})]
+ (is (string/includes?
+ (first (handle-cap ["REQ" ":multi-prefix"] c no-conn))
+ "NAK"))))
+ (testing "already authenticated"
+ (let [c (atom {:nick "x" :caps #{"sasl"} :authenticated? true})]
+ (is (string/includes?
+ (first (handle-authenticate ["PLAIN"] c no-conn))
+ "907")))))
+
+(deftest test_chanserv
+ (testing "REGISTER + INFO + SET"
+ (let [{:keys [test-network-id] :as components} (test-components-with-network)
+ out (java.io.ByteArrayOutputStream.)
+ c (registered-client "alice" out test-network-id)]
+ (handle-join ["#test"] c
+ (assoc components
+ :clients (atom {"alice" {:w out}})
+ :channels (atom {})))
+ (let [replies (handle-privmsg ["ChanServ" ":REGISTER #test"]
+ c components)]
+ (is (string/includes? (first replies) "has been registered")))
+ (let [replies (handle-privmsg ["ChanServ" ":INFO #test"]
+ c components)]
+ (is (some #(string/includes? % "alice") replies)))
+ (let [replies (handle-privmsg ["ChanServ" ":SET #test TOPIC Hello world"]
+ c components)]
+ (is (string/includes? (first replies) "Topic")))
+ (let [replies (handle-privmsg ["ChanServ" ":INFO #test"]
+ c components)]
+ (is (some #(string/includes? % "Hello world") replies)))))
+ (testing "OP and DEOP"
+ (let [{:keys [test-network-id] :as components} (test-components-with-network)
+ out (java.io.ByteArrayOutputStream.)
+ alice (registered-client "alice" out test-network-id)]
+ (handle-join ["#test"] alice
+ (assoc components
+ :clients (atom {"alice" {:w out}})
+ :channels (atom {})))
+ (handle-privmsg ["ChanServ" ":REGISTER #test"] alice components)
+ (let [replies (handle-privmsg ["ChanServ" ":OP #test bob"]
+ alice components)]
+ (is (string/includes? (first replies) "now an operator")))
+ (let [replies (handle-privmsg ["ChanServ" ":DEOP #test bob"]
+ alice components)]
+ (is (string/includes? (first replies) "no longer an operator")))))
+ (testing "KICK and BAN"
+ (let [alice-out (java.io.ByteArrayOutputStream.)
+ bob-out (java.io.ByteArrayOutputStream.)
+ {:keys [test-network-id] :as components}
+ (assoc (test-components-with-network)
+ :clients (atom {"alice" {:w alice-out}
+ "bob" {:w bob-out}})
+ :channels (atom {"#test" #{"alice" "bob"}}))
+ alice (registered-client "alice" alice-out test-network-id)
+ bob (registered-client "bob" bob-out test-network-id)]
+ (handle-join ["#test"] alice components)
+ (handle-privmsg ["ChanServ" ":REGISTER #test"] alice components)
+ (let [replies (handle-privmsg ["ChanServ" ":KICK #test bob bad behavior"]
+ alice components)]
+ (is (empty? replies))
+ (is (not (contains? (get @(:channels components) "#test") "bob")))
+ (is (string/includes? (.toString bob-out "UTF-8") "KICK")))
+ (let [replies (handle-privmsg ["ChanServ" ":BAN #test bob"]
+ alice components)]
+ (is (string/includes? (first replies) "banned")))
+ (let [replies (handle-join ["#test"] bob components)]
+ (is (string/includes? (first replies) "474")))))
+ (testing "permission denied for non-op"
+ (let [{:keys [test-network-id] :as components} (test-components-with-network)
+ out (java.io.ByteArrayOutputStream.)
+ alice (registered-client "alice" out test-network-id)
+ bob (registered-client "bob" out test-network-id)]
+ (handle-join ["#test"] alice
+ (assoc components
+ :clients (atom {"alice" {:w out}})
+ :channels (atom {})))
+ (handle-privmsg ["ChanServ" ":REGISTER #test"] alice components)
+ (let [replies (handle-privmsg ["ChanServ" ":SET #test TOPIC nope"]
+ bob components)]
+ (is (string/includes? (first replies) "Permission denied"))))))
+
+(deftest test_memoserv
+ (testing "SEND + LIST + READ + DELETE"
+ (let [components (test-components)
+ out (java.io.ByteArrayOutputStream.)
+ alice (registered-client "alice" out)
+ bob (registered-client "bob" out)]
+ ;; Send memo
+ (let [replies (handle-privmsg ["MemoServ" ":SEND bob Hello Bob!"]
+ alice components)]
+ (is (string/includes? (first replies) "Memo sent")))
+ ;; List as bob
+ (let [replies (handle-privmsg ["MemoServ" ":LIST"] bob components)]
+ (is (= 1 (count replies)))
+ (is (string/includes? (first replies) "alice"))
+ (is (string/includes? (first replies) "unread"))
+ ;; Extract memo ID for READ/DELETE
+ (let [memo-id (last (string/split (first replies) #" "))]
+ ;; Read
+ (let [replies (handle-privmsg
+ ["MemoServ" (str ":READ " memo-id)]
+ bob components)]
+ (is (string/includes? (first replies) "Hello Bob!")))
+ ;; Now listed as read
+ (let [replies (handle-privmsg ["MemoServ" ":LIST"] bob components)]
+ (is (string/includes? (first replies) "read")))
+ ;; Delete
+ (let [replies (handle-privmsg
+ ["MemoServ" (str ":DELETE " memo-id)]
+ bob components)]
+ (is (string/includes? (first replies) "deleted")))
+ ;; List empty
+ (let [replies (handle-privmsg ["MemoServ" ":LIST"] bob components)]
+ (is (string/includes? (first replies) "No memos")))))))
+ (testing "auto-delivery on registration"
+ (let [components (dissoc (test-components) :cracha)
+ out (java.io.ByteArrayOutputStream.)
+ alice (registered-client "alice" out)]
+ ;; Send memo to bob while offline
+ (handle-privmsg ["MemoServ" ":SEND bob Hey!"] alice components)
+ ;; Bob registers (no cracha = auth skipped)
+ (let [c (atom {:nick nil :user nil :pass nil :registered? false
+ :w out})]
+ (swap! c assoc :nick "bob")
+ (let [replies (@#'papod/replies-for!
+ {:command "USER" :params ["" "bob" "0" "x" ":B"]}
+ c components)]
+ (is (= 2 (count replies)))
+ (is (string/includes? (first replies) "001"))
+ (is (string/includes? (second replies) "MemoServ")))))))
+
+(deftest test_schema-invariants
+ (testing "events get monotonic sequence numbers per channel"
+ (let [alice-out (java.io.ByteArrayOutputStream.)
+ bob-out (java.io.ByteArrayOutputStream.)
+ {:keys [test-network-id] :as components}
+ (assoc (test-components-with-network)
+ :clients (atom {"alice" {:w alice-out}
+ "bob" {:w bob-out}})
+ :channels (atom {}))
+ conn (:conn components)
+ alice (registered-client "alice" alice-out test-network-id)]
+ (handle-join ["#test"] alice components)
+ (swap! (:channels components) update "#test" conj "bob")
+ (handle-privmsg ["#test" ":first"] alice components)
+ (handle-privmsg ["#test" ":second"] alice components)
+ (let [db (d/db conn)
+ seqs (sort (map first
+ (d/q '{:find [?seq]
+ :in [$ ?chan-name]
+ :where [[?e :papod.event/seq ?seq]
+ [?e :papod.event/channel ?c]
+ [?c :papod.channel/name ?chan-name]]}
+ db "#test")))]
+ (is (= [1 2 3] seqs)))))
+ (testing "duplicate access entry is rejected"
+ (let [{:keys [test-network-id] :as components} (test-components-with-network)
+ conn (:conn components)
+ out (java.io.ByteArrayOutputStream.)
+ alice (registered-client "alice" out test-network-id)]
+ (handle-join ["#test"] alice
+ (assoc components
+ :clients (atom {"alice" {:w out}})
+ :channels (atom {})))
+ (handle-privmsg ["ChanServ" ":REGISTER #test"] alice components)
+ (handle-privmsg ["ChanServ" ":OP #test bob"] alice components)
+ ;; Second OP for same nick is caught by has-access? check
+ (let [replies (handle-privmsg ["ChanServ" ":OP #test bob"]
+ alice components)]
+ (is (string/includes? (first replies) "already an operator")))))
+ (testing "non-empty predicates reject empty strings"
+ (let [components (test-components)
+ conn (:conn components)]
+ (is (thrown? java.util.concurrent.ExecutionException
+ @(d/transact conn
+ [{:db/ensure :papod.memo/attrs
+ :papod.memo/id (java.util.UUID/randomUUID)
+ :papod.memo/from ""
+ :papod.memo/to "bob"
+ :papod.memo/content "hi"
+ :papod.memo/created-at (java.util.Date.)
+ :papod.memo/read? false}])))))
+ (testing "entity attrs enforce required fields"
+ (let [components (test-components)
+ conn (:conn components)]
+ (is (thrown? java.util.concurrent.ExecutionException
+ @(d/transact conn
+ [{:db/ensure :papod.memo/attrs
+ :papod.memo/id (java.util.UUID/randomUUID)
+ :papod.memo/from "alice"}]))))))
-; (parse-message " CMD")
+(deftest test_private-channels
+ (testing "private channel created via ChanServ is joinable by & handle"
+ (let [alice-out (java.io.ByteArrayOutputStream.)
+ bob-out (java.io.ByteArrayOutputStream.)
+ {:keys [test-network-id] :as components}
+ (assoc (test-components-with-network)
+ :clients (atom {"alice" {:w alice-out}
+ "bob" {:w bob-out}})
+ :channels (atom {}))
+ alice (registered-client "alice" alice-out test-network-id)
+ bob (registered-client "bob" bob-out test-network-id)
+ conn (:conn components)
+ chan-id (java.util.UUID/randomUUID)
+ _ @(d/transact conn
+ [{:db/ensure :papod.channel/attrs
+ :db/id "new-channel"
+ :papod.channel/id chan-id
+ :papod.channel/network [:papod.network/id test-network-id]
+ :papod.channel/type "private"
+ :papod.channel/label "secret-project"
+ :papod.channel/description ""
+ :papod.channel/created-at (java.util.Date.)}
+ {:db/ensure :papod.access/attrs
+ :papod.access/id (java.util.UUID/randomUUID)
+ :papod.access/channel "new-channel"
+ :papod.access/nick "alice"
+ :papod.access/level "owner"}])
+ handle (str "&" chan-id)]
+ ;; Alice (owner) can join
+ (let [replies (handle-join [handle] alice components)]
+ (is (empty? replies))
+ (is (contains? (get @(:channels components) handle) "alice")))
+ ;; Bob (no access) cannot join — gets "No such channel"
+ (let [replies (handle-join [handle] bob components)]
+ (is (string/includes? (first replies) "403")))))
+ (testing "probing #private-name returns no such channel"
+ (let [{:keys [test-network-id] :as components} (test-components-with-network)
+ conn (:conn components)
+ out (java.io.ByteArrayOutputStream.)
+ alice (registered-client "alice" out test-network-id)
+ _ @(d/transact conn
+ [{:db/ensure :papod.channel/attrs
+ :papod.channel/id (java.util.UUID/randomUUID)
+ :papod.channel/network [:papod.network/id test-network-id]
+ :papod.channel/type "private"
+ :papod.channel/label "bob-firing-wg"
+ :papod.channel/description ""
+ :papod.channel/created-at (java.util.Date.)}])]
+ ;; Probing by name fails — no public name exists
+ (let [replies (handle-join ["#bob-firing-wg"] alice
+ (assoc components :channels (atom {})))]
+ ;; Creates a NEW public channel named #bob-firing-wg
+ ;; (doesn't find the private one — that's the point)
+ (is (empty? replies)))))
+ (testing "#-prefixed UUID name works as a normal public channel"
+ (let [{:keys [test-network-id] :as components}
+ (assoc (test-components-with-network)
+ :clients (atom {})
+ :channels (atom {}))
+ out (java.io.ByteArrayOutputStream.)
+ alice (registered-client "alice" out test-network-id)
+ uuid-name (str "#" (java.util.UUID/randomUUID))]
+ ;; JOIN with a UUID-shaped name — treated as a public channel name
+ (let [c (assoc components
+ :clients (atom {"alice" {:w out}}))]
+ (let [replies (handle-join [uuid-name] alice c)]
+ (is (empty? replies))
+ (is (contains? (get @(:channels c) uuid-name) "alice"))))))
+ (testing "&non-uuid is rejected"
+ (let [{:keys [test-network-id] :as components}
+ (assoc (test-components-with-network) :channels (atom {}))
+ out (java.io.ByteArrayOutputStream.)
+ alice (registered-client "alice" out test-network-id)]
+ (let [replies (handle-join ["&not-a-uuid"] alice components)]
+ (is (string/includes? (first replies) "403"))))))
+(deftest test_networks-and-members
+ (testing "registration creates member in connection's network"
+ (let [{:keys [test-network-id] :as components}
+ (dissoc (test-components-with-network) :cracha)
+ conn (:conn components)
+ out (java.io.ByteArrayOutputStream.)
+ c (atom {:nick nil :user nil :pass nil :registered? false
+ :w out :network-id test-network-id})]
+ (swap! c assoc :nick "alice")
+ (@#'papod/replies-for!
+ {:command "USER" :params ["" "alice" "0" "x" ":A"]}
+ c components)
+ (is (:registered? @c))
+ (let [db (d/db conn)
+ members (d/q '{:find [?nick ?status]
+ :in [$ ?net]
+ :where [[?m :papod.member/network ?net]
+ [?m :papod.member/nick ?nick]
+ [?m :papod.member/status ?status]]}
+ db [:papod.network/id test-network-id])]
+ (is (= #{["alice" "active"]} members)))))
+ (testing "no member created without NETWORK command"
+ (let [components (dissoc (test-components-with-network) :cracha)
+ conn (:conn components)
+ out (java.io.ByteArrayOutputStream.)
+ c (atom {:nick nil :user nil :pass nil :registered? false
+ :w out})]
+ (swap! c assoc :nick "bob")
+ (@#'papod/replies-for!
+ {:command "USER" :params ["" "bob" "0" "x" ":B"]}
+ c components)
+ (is (:registered? @c))
+ (let [db (d/db conn)
+ members (d/q '{:find [?nick]
+ :where [[?m :papod.member/nick ?nick]]}
+ db)]
+ (is (empty? members)))))
+ (testing "channels are scoped to network"
+ (let [{:keys [test-network-id] :as components}
+ (assoc (test-components-with-network)
+ :clients (atom {})
+ :channels (atom {}))
+ conn (:conn components)
+ out (java.io.ByteArrayOutputStream.)
+ alice (registered-client "alice" out test-network-id)]
+ (handle-join ["#general"] alice
+ (assoc components :clients (atom {"alice" {:w out}})))
+ (let [db (d/db conn)
+ chans (d/q '{:find [?name ?net-id]
+ :in [$ ?net]
+ :where [[?c :papod.channel/name ?name]
+ [?c :papod.channel/network ?net]
+ [?net :papod.network/id ?net-id]]}
+ db [:papod.network/id test-network-id])]
+ (is (= 1 (count chans)))
+ (is (= "#general" (ffirst chans)))
+ (is (= test-network-id (second (first chans)))))))
+ (testing "network has required attrs"
+ (let [conn (:conn (test-components))]
+ (is (thrown? java.util.concurrent.ExecutionException
+ @(d/transact conn
+ [{:db/ensure :papod.network/attrs
+ :papod.network/id (java.util.UUID/randomUUID)
+ :papod.network/name "incomplete"}]))))))
(defn -main
[& _args]