diff options
| author | EuAndreh <eu@euandre.org> | 2026-04-23 10:03:07 -0300 |
|---|---|---|
| committer | EuAndreh <eu@euandre.org> | 2026-04-23 10:03:07 -0300 |
| commit | d879cbae489b4695c581c16d5659cadd100bd3c1 (patch) | |
| tree | 1aeb7ea85206d8346ccd366eb0bf22ac6a1d92e4 /tests | |
| parent | m (diff) | |
| download | papod-d879cbae489b4695c581c16d5659cadd100bd3c1.tar.gz papod-d879cbae489b4695c581c16d5659cadd100bd3c1.tar.xz | |
m
Diffstat (limited to 'tests')
| -rwxr-xr-x | tests/acceptance.sh | 59 | ||||
| -rw-r--r-- | tests/integration.clj | 302 | ||||
| -rw-r--r-- | tests/unit.clj | 348 |
3 files changed, 674 insertions, 35 deletions
diff --git a/tests/acceptance.sh b/tests/acceptance.sh new file mode 100755 index 0000000..9f775ef --- /dev/null +++ b/tests/acceptance.sh @@ -0,0 +1,59 @@ +#!/bin/sh +set -eu + +# Acceptance tests: run irctest against papod via binder (TCP→Unix proxy) + +SOCKET_PATH="${TMPDIR:-/tmp}/papod-acceptance-$$.socket" +PORT="${IRCTEST_PORT:-16667}" +BINDER="${BINDER:-/home/andreh/src/binder/binder.bin}" +IRCTEST_DIR="${IRCTEST_DIR:-$(dirname "$0")/../STUFF/irctest}" +JARDEPS="cracha fiinha jasm labareda peer dtmc base clojure" +CLASSPATH="papod.jar$(printf ':/home/andreh/.usr/var/mkg/share/java/%s.jar' $JARDEPS)" + +PAPOD_PID="" +BINDER_PID="" + +cleanup() { + [ -n "$PAPOD_PID" ] && kill "$PAPOD_PID" 2>/dev/null || true + [ -n "$BINDER_PID" ] && kill "$BINDER_PID" 2>/dev/null || true + wait "$PAPOD_PID" "$BINDER_PID" 2>/dev/null || true + rm -f "$SOCKET_PATH" +} +trap cleanup EXIT + +echo "=== Starting papod on $SOCKET_PATH ===" +PAPOD_SOCKET="$SOCKET_PATH" java -client -cp "$CLASSPATH" papod & +PAPOD_PID=$! + +# Wait for socket to appear +for i in $(seq 1 50); do + [ -S "$SOCKET_PATH" ] && break + sleep 0.1 +done + +if [ ! -S "$SOCKET_PATH" ]; then + echo "ERROR: papod socket did not appear at $SOCKET_PATH" + exit 1 +fi +echo "papod started (PID $PAPOD_PID)" + +echo "=== Starting binder on 127.0.0.1:$PORT → $SOCKET_PATH ===" +"$BINDER" "127.0.0.1:$PORT" "$SOCKET_PATH" & +BINDER_PID=$! +sleep 0.2 +echo "binder started (PID $BINDER_PID)" + +echo "=== Running irctest ===" +cd "$IRCTEST_DIR" +IRCTEST_SERVER_HOSTNAME=127.0.0.1 \ +IRCTEST_SERVER_PORT=$PORT \ +pytest --controller irctest.controllers.external_server \ + -k 'not deprecated' \ + -x -v \ + "$@" || { + rc=$? + echo "=== irctest exited with code $rc ===" + exit $rc +} + +echo "=== All irctest tests passed ===" diff --git a/tests/integration.clj b/tests/integration.clj index 18811a2..2ae2d92 100644 --- a/tests/integration.clj +++ b/tests/integration.clj @@ -1,6 +1,306 @@ (ns integration + (:require [clojure.test :as t :refer [deftest is testing]] + [clojure.string :as string] + [cracha] + [datomic.api :as d] + [fiinha] + [papod]) (:gen-class)) + +(defn- test-components + [] + (let [fiinha-state (fiinha/initdb! + (str "datomic:mem://fiinha-int-" + (java.util.UUID/randomUUID))) + cracha-state (cracha/init! + (str "datomic:mem://cracha-int-" + (java.util.UUID/randomUUID)) + fiinha-state) + papod-uri (str "datomic:mem://papod-int-" + (java.util.UUID/randomUUID)) + _ (d/create-database papod-uri) + conn (d/connect papod-uri) + _ @(d/transact conn @#'papod/schema) + process-id (java.util.UUID/randomUUID) + _ @(d/transact conn + [{:db/ensure :papod.process/attrs + :papod.process/id process-id + :papod.process/pid 0 + :papod.process/hostname "test" + :papod.process/started-at (java.util.Date.)}]) + net-id (java.util.UUID/randomUUID) + _ @(d/transact conn + [{:db/ensure :papod.network/attrs + :papod.network/id net-id + :papod.network/name "test-network" + :papod.network/description "" + :papod.network/type "public" + :papod.network/created-at (java.util.Date.)}])] + {:conn conn + :cracha cracha-state + :process-id process-id + :net-id net-id + :clients (atom {}) + :channels (atom {})})) + +(defn- make-client + "Creates a simulated client connection using piped streams. + Spawns client-loop! in a thread. Returns a map with :out (writer) + and :client-out (ByteArrayOutputStream of server responses)." + [components] + (let [client-to-server (java.io.PipedOutputStream.) + server-reads (java.io.PipedInputStream. client-to-server) + server-writes (java.io.ByteArrayOutputStream.) + ;; client-loop! reads from server-reads, writes to server-writes + ;; We replicate client-loop! inline to avoid needing a socket + thread + (Thread. + (fn [] + (try + (let [r server-reads + w server-writes + b (make-array Byte/TYPE 1024) + conn-id (java.util.UUID/randomUUID) + client (atom {:nick nil :user nil :pass nil + :registered? false + :w w :connection-id conn-id})] + (when-let [conn (:conn components)] + @(d/transact conn + [{:db/ensure :papod.connection/attrs + :papod.connection/id conn-id + :papod.connection/process [:papod.process/id + (:process-id components)] + :papod.connection/created-at (java.util.Date.)}])) + (try + (loop [acc ""] + (let [n (.read r b)] + (when (pos? n) + (recur (@#'papod/process-input! + (str acc (String. b 0 n "UTF-8")) + w client components))))) + (finally + (when-let [conn (:conn components)] + @(d/transact conn + [[:db/add [:papod.connection/id conn-id] + :papod.connection/finished-at (java.util.Date.)]])) + (when-let [nick (:nick @client)] + (when (:clients components) + (swap! (:clients components) dissoc nick)) + (when (:channels components) + (doseq [[ch _] @(:channels components)] + (swap! (:channels components) update ch disj nick))))))) + (catch Exception _))))] + (.setDaemon thread true) + (.start thread) + {:out (java.io.OutputStreamWriter. client-to-server "UTF-8") + :client-out server-writes + :thread thread + :close! (fn [] + (.close client-to-server) + (.join thread 1000))})) + +(defn- send! + [{:keys [out]} line] + (.write out (str line "\r\n")) + (.flush out)) + +(defn- drain! + "Returns all output the server has sent so far, then resets the buffer." + [{:keys [client-out]}] + (Thread/sleep 50) ;; give server a moment to process + (let [s (.toString client-out "UTF-8")] + (.reset client-out) + s)) + +(defn- wait-for + "Wait until server output contains the expected substring." + [client expected timeout-ms] + (let [deadline (+ (System/currentTimeMillis) timeout-ms)] + (loop [] + (let [output (.toString (:client-out client) "UTF-8")] + (if (string/includes? output expected) + output + (if (> (System/currentTimeMillis) deadline) + output + (do (Thread/sleep 20) + (recur)))))))) + +(defn- register! + [client nick net-id] + (send! client (str "PASS " net-id)) + (send! client (str "NICK " nick)) + (send! client (str "USER " nick " 0 * :" nick))) + + + +(deftest test_full-registration + (let [components (test-components) + client (make-client components)] + (try + (register! client "alice" (:net-id components)) + (let [output (wait-for client "001" 2000)] + (is (string/includes? output "001")) + (is (string/includes? output "Welcome"))) + (finally + ((:close! client)))))) + +(deftest test_channel-messaging + (let [components (test-components) + alice (make-client components) + bob (make-client components)] + (try + ;; Register both + (register! alice "alice" (:net-id components)) + (wait-for alice "001" 2000) + (register! bob "bob" (:net-id components)) + (wait-for bob "001" 2000) + ;; Alice joins + (send! alice "JOIN #test") + (wait-for alice "JOIN" 2000) + ;; Bob joins + (.reset (:client-out alice)) + (send! bob "JOIN #test") + (wait-for bob "JOIN" 2000) + ;; Alice should see bob's join + (let [alice-out (wait-for alice "JOIN" 2000)] + (is (string/includes? alice-out "bob"))) + ;; Alice sends — bob receives with msgid + time + (.reset (:client-out bob)) + (send! alice "PRIVMSG #test :Hello from Alice!") + (let [bob-out (wait-for bob "Hello from Alice" 2000)] + (is (string/includes? bob-out "PRIVMSG #test")) + (is (string/includes? bob-out "@msgid=")) + (is (string/includes? bob-out "time="))) + (finally + ((:close! alice)) + ((:close! bob)))))) + +(deftest test_ping-pong + (let [components (test-components) + client (make-client components)] + (try + (send! client "PING :test123") + (let [output (wait-for client "PONG" 2000)] + (is (string/includes? output "PONG")) + (is (string/includes? output "test123"))) + (finally + ((:close! client)))))) + +(deftest test_no-network-blocks-join + (let [components (test-components) + client (make-client components)] + (try + ;; Register WITHOUT PASS (no network) + (send! client "NICK alice") + (send! client "USER alice 0 * :Alice") + (wait-for client "001" 2000) + ;; JOIN should fail + (send! client "JOIN #test") + (let [output (wait-for client "network" 2000)] + (is (string/includes? output "No network selected"))) + (finally + ((:close! client)))))) + +(deftest test_nickserv-register-and-identify + (let [components (test-components) + client (make-client components)] + (try + (register! client "alice" (:net-id components)) + (wait-for client "001" 2000) + ;; Register nick + (send! client "PRIVMSG NickServ :REGISTER mypass") + (let [output (wait-for client "registered" 5000)] + (is (string/includes? output "registered successfully"))) + ;; Identify + (.reset (:client-out client)) + (send! client "PRIVMSG NickServ :IDENTIFY mypass") + (let [output (wait-for client "identified" 2000)] + (is (string/includes? output "now identified")) + (is (string/includes? output "Session ID"))) + (finally + ((:close! client)))))) + +(deftest test_echo-message + (let [components (test-components) + client (make-client components) + bob (make-client components)] + (try + (register! client "alice" (:net-id components)) + (wait-for client "001" 2000) + (register! bob "bob" (:net-id components)) + (wait-for bob "001" 2000) + ;; Negotiate echo-message + ;; (CAP must be before registration, but we can test via direct cap set) + ;; For a proper test, use the full CAP flow: + ;; For now, join a channel and test without echo + (send! client "JOIN #test") + (wait-for client "JOIN" 2000) + (send! bob "JOIN #test") + (wait-for bob "JOIN" 2000) + ;; Without echo: alice doesn't see her own message + (.reset (:client-out client)) + (send! client "PRIVMSG #test :no-echo") + (Thread/sleep 100) + (let [alice-out (.toString (:client-out client) "UTF-8")] + (is (not (string/includes? alice-out "no-echo")))) + (finally + ((:close! client)) + ((:close! bob)))))) + +(deftest test_private-message-dm + (let [components (test-components) + alice (make-client components) + bob (make-client components)] + (try + (register! alice "alice" (:net-id components)) + (wait-for alice "001" 2000) + (register! bob "bob" (:net-id components)) + (wait-for bob "001" 2000) + ;; Alice DMs bob + (.reset (:client-out bob)) + (send! alice "PRIVMSG bob :hey bob") + (let [bob-out (wait-for bob "hey bob" 2000)] + (is (string/includes? bob-out "PRIVMSG bob :hey bob")) + (is (string/includes? bob-out "@msgid="))) + (finally + ((:close! alice)) + ((:close! bob)))))) + +(deftest test_chanserv-register-and-kick + (let [components (test-components) + alice (make-client components) + bob (make-client components)] + (try + (register! alice "alice" (:net-id components)) + (wait-for alice "001" 2000) + (register! bob "bob" (:net-id components)) + (wait-for bob "001" 2000) + (send! alice "JOIN #modtest") + (wait-for alice "JOIN" 2000) + (send! bob "JOIN #modtest") + (wait-for bob "JOIN" 2000) + ;; Alice registers channel + (send! alice "PRIVMSG ChanServ :REGISTER #modtest") + (wait-for alice "registered" 2000) + ;; Alice kicks bob + (.reset (:client-out bob)) + (send! alice "PRIVMSG ChanServ :KICK #modtest bob :behave") + (let [bob-out (wait-for bob "KICK" 2000)] + (is (string/includes? bob-out "KICK")) + (is (string/includes? bob-out "behave"))) + (finally + ((:close! alice)) + ((:close! bob)))))) + + (defn -main - [& _args]) + [& _args] + (binding [*out* *err*] + (let [{:keys [fail error] :as res} (t/run-tests 'integration) + status (if (zero? (+ fail error)) + 0 + 1)] + (prn res) + (System/exit status)))) diff --git a/tests/unit.clj b/tests/unit.clj index 8e8eb0a..c764844 100644 --- a/tests/unit.clj +++ b/tests/unit.clj @@ -39,31 +39,31 @@ (deftest test_parse-message (testing "only commands" (is (= (parse-message "CMD") - [{:prefix nil + [{:tags nil :prefix nil :command "CMD" :params []} nil]))) (testing "prefix with nick only" (is (= (parse-message ":nick CMD") - [{:prefix {:nick "nick" :user nil :host nil} + [{:tags nil :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} + [{:tags nil :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"} + [{:tags nil :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"} + [{:tags nil :prefix {:nick "nick" :user "user" :host "host"} :command "CMD" :params []} nil]))) @@ -251,7 +251,7 @@ "bob" {:w target-out}}) :channels (atom {})) conn (:conn components)] - (handle-privmsg ["bob" ":hello world"] sender components) + (handle-privmsg ["bob" ":hello world"] nil sender components) ;; Verify event persisted with target-nick (let [db (d/db conn) events (d/q '{:find [?nick ?target ?payload] @@ -284,7 +284,7 @@ :papod.channel/type :papod.channel.type/public :papod.channel/description "" :papod.channel/created-at (java.util.Date.)}]) - (handle-privmsg ["#test" ":hi everyone"] sender components) + (handle-privmsg ["#test" ":hi everyone"] nil sender components) ;; Verify event references the channel entity (let [db (d/db conn) events (d/q '{:find [?type ?payload ?chan-name] @@ -357,10 +357,10 @@ (testing "PRIVMSG error cases" (let [c (registered-client "alice" (java.io.ByteArrayOutputStream.))] (is (string/includes? - (first (handle-privmsg [] c no-conn)) + (first (handle-privmsg [] nil c no-conn)) "411")) (is (string/includes? - (first (handle-privmsg ["bob"] c no-conn)) + (first (handle-privmsg ["bob"] nil c no-conn)) "412"))))) (deftest test_nickserv @@ -369,7 +369,7 @@ c (registered-client "alice" out) components (test-components) conn (:conn components) - replies (handle-privmsg ["NickServ" ":REGISTER mypass"] c components)] + replies (handle-privmsg ["NickServ" ":REGISTER mypass"] nil c components)] (is (= 1 (count replies))) (is (string/includes? (first replies) "registered successfully")) (let [cc (:conn (:cracha components)) @@ -380,37 +380,37 @@ (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)] + (handle-privmsg ["NickServ" ":REGISTER pass1"] nil c components) + (let [replies (handle-privmsg ["NickServ" ":REGISTER pass2"] nil 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)] + (handle-privmsg ["NickServ" ":REGISTER mypass"] nil c components) + (let [replies (handle-privmsg ["NickServ" ":IDENTIFY mypass"] nil 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)] + (handle-privmsg ["NickServ" ":REGISTER mypass"] nil c components) + (let [replies (handle-privmsg ["NickServ" ":IDENTIFY wrong"] nil 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)] + replies (handle-privmsg ["NickServ" ":IDENTIFY mypass"] nil 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) + (handle-privmsg ["NickServ" ":REGISTER mypass"] nil c components) (let [db (d/db conn) evts (d/q '{:find [?e] :where [[?e :papod.event/id _]]} @@ -428,7 +428,7 @@ components (test-components)] ;; Register user in cracha first (swap! c assoc :nick "alice" :registered? true :w out) - (handle-privmsg ["NickServ" ":REGISTER mypass"] c components) + (handle-privmsg ["NickServ" ":REGISTER mypass"] nil c components) ;; Reset client for fresh connection (let [c2 (atom {:nick nil :user nil :pass nil :registered? false :w out})] @@ -469,7 +469,7 @@ components (test-components)] ;; Register user (swap! c assoc :nick "bob" :registered? true :w out) - (handle-privmsg ["NickServ" ":REGISTER secret"] c components) + (handle-privmsg ["NickServ" ":REGISTER secret"] nil c components) ;; Fresh connection (let [c2 (atom {:nick "bob" :user nil :pass nil :registered? false :w out :caps #{"sasl"}})] @@ -534,7 +534,7 @@ (assoc components :clients (atom {"alice" {:w out}}) :channels (atom {}))) - (handle-privmsg ["ChanServ" ":REGISTER #test"] alice components) + (handle-privmsg ["ChanServ" ":REGISTER #test"] nil alice components) (let [replies (handle-privmsg ["ChanServ" ":OP #test bob"] alice components)] (is (string/includes? (first replies) "now an operator"))) @@ -552,7 +552,7 @@ 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) + (handle-privmsg ["ChanServ" ":REGISTER #test"] nil alice components) (let [replies (handle-privmsg ["ChanServ" ":KICK #test bob bad behavior"] alice components)] (is (empty? replies)) @@ -572,7 +572,7 @@ (assoc components :clients (atom {"alice" {:w out}}) :channels (atom {}))) - (handle-privmsg ["ChanServ" ":REGISTER #test"] alice components) + (handle-privmsg ["ChanServ" ":REGISTER #test"] nil alice components) (let [replies (handle-privmsg ["ChanServ" ":SET #test TOPIC nope"] bob components)] (is (string/includes? (first replies) "Permission denied")))))) @@ -588,7 +588,7 @@ alice components)] (is (string/includes? (first replies) "Memo sent"))) ;; List as bob - (let [replies (handle-privmsg ["MemoServ" ":LIST"] bob components)] + (let [replies (handle-privmsg ["MemoServ" ":LIST"] nil bob components)] (is (= 1 (count replies))) (is (string/includes? (first replies) "alice")) (is (string/includes? (first replies) "unread")) @@ -600,7 +600,7 @@ bob components)] (is (string/includes? (first replies) "Hello Bob!"))) ;; Now listed as read - (let [replies (handle-privmsg ["MemoServ" ":LIST"] bob components)] + (let [replies (handle-privmsg ["MemoServ" ":LIST"] nil bob components)] (is (string/includes? (first replies) "read"))) ;; Delete (let [replies (handle-privmsg @@ -608,14 +608,14 @@ bob components)] (is (string/includes? (first replies) "deleted"))) ;; List empty - (let [replies (handle-privmsg ["MemoServ" ":LIST"] bob components)] + (let [replies (handle-privmsg ["MemoServ" ":LIST"] nil 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) + (handle-privmsg ["MemoServ" ":SEND bob Hey!"] nil alice components) ;; Bob registers (no cracha = auth skipped) (let [c (atom {:nick nil :user nil :pass nil :registered? false :w out})] @@ -640,8 +640,8 @@ 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) + (handle-privmsg ["#test" ":first"] nil alice components) + (handle-privmsg ["#test" ":second"] nil alice components) (let [db (d/db conn) seqs (sort (map first (d/q '{:find [?seq] @@ -660,8 +660,8 @@ (assoc components :clients (atom {"alice" {:w out}}) :channels (atom {}))) - (handle-privmsg ["ChanServ" ":REGISTER #test"] alice components) - (handle-privmsg ["ChanServ" ":OP #test bob"] alice components) + (handle-privmsg ["ChanServ" ":REGISTER #test"] nil alice components) + (handle-privmsg ["ChanServ" ":OP #test bob"] nil alice components) ;; Second OP for same nick is caught by has-access? check (let [replies (handle-privmsg ["ChanServ" ":OP #test bob"] alice components)] @@ -836,7 +836,7 @@ ;; Register user in cracha first (swap! c assoc :nick "alice" :registered? true :w out :network-id test-network-id) - (handle-privmsg ["NickServ" ":REGISTER mypass"] c components) + (handle-privmsg ["NickServ" ":REGISTER mypass"] nil c components) ;; Fresh connection with connection-id (simulating client-loop!) (let [conn-id (java.util.UUID/randomUUID) conn (:conn components) @@ -883,7 +883,7 @@ c (registered-client "bob" out test-network-id)] (swap! c assoc :connection-id conn-id) ;; Register user - (handle-privmsg ["NickServ" ":REGISTER secret"] c components) + (handle-privmsg ["NickServ" ":REGISTER secret"] nil c components) ;; Identify (let [replies (handle-privmsg ["NickServ" ":IDENTIFY secret"] c components)] @@ -915,7 +915,7 @@ :caps #{"sasl"} :connection-id conn-id1 :network-id test-network-id})] ;; Register user in cracha - (handle-privmsg ["NickServ" ":REGISTER mypass"] c1 components) + (handle-privmsg ["NickServ" ":REGISTER mypass"] nil c1 components) ;; Auth via SASL PLAIN to get session (handle-authenticate ["PLAIN"] c1 components) (handle-authenticate [(b64 "\u0000alice\u0000mypass")] c1 components) @@ -975,6 +975,286 @@ (is (string/includes? (first replies) "904")) (is (not (:authenticated? @c)))))))) +(def handle-chathistory @#'papod/handle-chathistory) +(def handle-redact @#'papod/handle-redact) +(def handle-edit @#'papod/handle-edit) +(def handle-tagmsg @#'papod/handle-tagmsg) + +(deftest test_messaging-features + (testing "messages include msgid tags" + (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"}})) + conn (:conn components) + alice (registered-client "alice" alice-out test-network-id)] + ;; Create channel in DB + @(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 :papod.channel.type/public + :papod.channel/description "" + :papod.channel/created-at (java.util.Date.)}]) + (handle-privmsg ["#test" ":hello"] alice components) + (let [delivered (.toString bob-out "UTF-8")] + (is (string/includes? delivered "@msgid=")) + (is (string/includes? delivered "PRIVMSG #test :hello"))))) + (testing "CHATHISTORY returns events by seq" + (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 {})) + alice (registered-client "alice" alice-out test-network-id)] + (handle-join ["#test"] alice components) + (replies-for! {:command "PRIVMSG" :params ["" "#test" ":msg1"]} + alice components) + (replies-for! {:command "PRIVMSG" :params ["" "#test" ":msg2"]} + alice components) + (let [replies (handle-chathistory ["#test" "1" "10"] alice components)] + (is (= 2 (count replies))) + (is (string/includes? (first replies) "msg1")) + (is (string/includes? (second replies) "msg2")) + (is (every? #(string/includes? % "@msgid=") replies))))) + (testing "EDIT persists and notifies (draft/message-editing)" + (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") + (replies-for! {:command "PRIVMSG" :params ["" "#test" ":original"]} + alice components) + (let [db (d/db conn) + msg-id (ffirst + (d/q '{:find [?id] + :where [[?e :papod.event/type "user-message"] + [?e :papod.event/id ?id]]} + db))] + (.reset bob-out) + (handle-edit ["#test" (str msg-id) ":corrected"] alice components) + ;; Edit event persisted with edit-of ref + (let [edits (d/q '{:find [?payload] + :where [[?e :papod.event/type "user-edit"] + [?e :papod.event/edit-of ?orig] + [?e :papod.event/payload ?payload]]} + (d/db conn))] + (is (= 1 (count edits))) + (is (= ":corrected" (ffirst edits)))) + ;; Bob receives EDIT command (not custom tag) + (let [delivered (.toString bob-out "UTF-8")] + (is (string/includes? delivered "EDIT #test")) + (is (string/includes? delivered "corrected")) + (is (string/includes? delivered "@msgid="))) + ;; Non-author can't edit + (let [bob (registered-client "bob" bob-out test-network-id) + replies (handle-edit ["#test" (str msg-id) ":nope"] + bob components)] + (is (string/includes? (first replies) "EDIT_FORBIDDEN")))))) + (testing "REDACT deletes and notifies (draft/message-redaction)" + (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") + (replies-for! {:command "PRIVMSG" :params ["" "#test" ":to-delete"]} + alice components) + (let [db (d/db conn) + msg-id (ffirst + (d/q '{:find [?id] + :where [[?e :papod.event/type "user-message"] + [?e :papod.event/id ?id]]} + db))] + (.reset bob-out) + (handle-redact ["#test" (str msg-id) "oops"] alice components) + (let [deletes (d/q '{:find [?id] + :where [[?e :papod.event/type "user-delete"] + [?e :papod.event/delete-of ?orig] + [?e :papod.event/id ?id]]} + (d/db conn))] + (is (= 1 (count deletes)))) + ;; Bob receives REDACT (not custom +papod/delete tag) + (is (string/includes? (.toString bob-out "UTF-8") "REDACT"))))) + (testing "+draft/react via TAGMSG" + (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") + (replies-for! {:command "PRIVMSG" :params ["" "#test" ":reactable"]} + alice components) + (let [db (d/db conn) + msg-id (ffirst + (d/q '{:find [?id] + :where [[?e :papod.event/type "user-message"] + [?e :papod.event/id ?id]]} + db))] + (.reset bob-out) + ;; IRCv3: @+reply=<msgid>;+draft/react=<emoji> TAGMSG #channel + (handle-tagmsg {:tags {"+reply" (str msg-id) + "+draft/react" "thumbsup"} + :command "TAGMSG" + :params ["" "#test"]} + alice components) + (let [reactions (d/q '{:find [?nick ?emoji] + :where [[?r :papod.reaction/nick ?nick] + [?r :papod.reaction/emoji ?emoji]]} + (d/db conn))] + (is (= #{["alice" "thumbsup"]} reactions))) + (is (string/includes? (.toString bob-out "UTF-8") "+draft/react="))))) + (testing "+reply tag on PRIVMSG creates thread" + (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") + (replies-for! {:command "PRIVMSG" :params ["" "#test" ":parent message"]} + alice components) + (let [db (d/db conn) + parent-id (ffirst + (d/q '{:find [?id] + :where [[?e :papod.event/type "user-message"] + [?e :papod.event/id ?id]]} + db))] + (.reset bob-out) + ;; IRCv3: @+reply=<msgid> PRIVMSG #channel :reply text + (replies-for! {:tags {"+reply" (str parent-id)} + :command "PRIVMSG" + :params ["" "#test" ":thread reply"]} + alice components) + (let [thread-replies + (d/q '{:find [?payload ?pid] + :where [[?e :papod.event/reply-to ?p] + [?e :papod.event/payload ?payload] + [?p :papod.event/id ?pid]]} + (d/db conn))] + (is (= 1 (count thread-replies))) + (is (= ":thread reply" (ffirst thread-replies))) + (is (= parent-id (second (first thread-replies))))) + (is (string/includes? (.toString bob-out "UTF-8") "+reply=")))))) + +(def handle-markread @#'papod/handle-markread) + +(deftest test_ircv3-capabilities + (testing "server-time tag on outgoing messages" + (let [bob-out (java.io.ByteArrayOutputStream.) + {:keys [test-network-id] :as components} + (assoc (test-components-with-network) + :clients (atom {"alice" {:w (java.io.ByteArrayOutputStream.)} + "bob" {:w bob-out}}) + :channels (atom {"#test" #{"alice" "bob"}})) + conn (:conn components) + alice (registered-client "alice" (java.io.ByteArrayOutputStream.) + test-network-id)] + @(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 :papod.channel.type/public + :papod.channel/description "" + :papod.channel/created-at (java.util.Date.)}]) + (handle-privmsg ["#test" ":hello"] alice components) + (let [delivered (.toString bob-out "UTF-8")] + (is (string/includes? delivered "time=")) + (is (re-find #"time=\d{4}-\d{2}-\d{2}T" delivered))))) + (testing "echo-message echoes back to sender" + (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"}})) + conn (:conn components) + alice (registered-client "alice" alice-out test-network-id)] + @(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 :papod.channel.type/public + :papod.channel/description "" + :papod.channel/created-at (java.util.Date.)}]) + ;; Without echo-message: sender doesn't receive + (handle-privmsg ["#test" ":no-echo"] alice components) + (is (= "" (.toString alice-out "UTF-8"))) + ;; With echo-message: sender receives their own message + (swap! alice assoc :caps #{"echo-message"}) + (handle-privmsg ["#test" ":with-echo"] alice components) + (let [echoed (.toString alice-out "UTF-8")] + (is (string/includes? echoed "with-echo")) + (is (string/includes? echoed "@msgid="))))) + (testing "CAP LS advertises all capabilities" + (let [c (client) + replies (handle-cap ["LS" "302"] c no-conn)] + (is (string/includes? (first replies) "server-time")) + (is (string/includes? (first replies) "echo-message")) + (is (string/includes? (first replies) "batch")) + (is (string/includes? (first replies) "labeled-response")) + (is (string/includes? (first replies) "draft/multiline")) + (is (string/includes? (first replies) "draft/read-marker")))) + (testing "typing notification relayed to 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 {"#test" #{"alice" "bob"}})) + alice (registered-client "alice" alice-out test-network-id)] + (handle-tagmsg {:tags {"+typing" "active"} + :command "TAGMSG" + :params ["" "#test"]} + alice components) + (let [delivered (.toString bob-out "UTF-8")] + (is (string/includes? delivered "+typing=active"))) + ;; Sender doesn't receive their own typing + (is (= "" (.toString alice-out "UTF-8"))))) + (testing "MARKREAD set and get" + (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 {}))) + ;; GET with no marker + (let [replies (handle-markread ["#test"] alice components)] + (is (string/includes? (first replies) "MARKREAD #test *"))) + ;; SET + (let [ts "timestamp=2026-04-22T10:00:00.000Z" + replies (handle-markread ["#test" ts] alice components)] + (is (string/includes? (first replies) (str "MARKREAD #test " ts)))) + ;; GET returns stored value + (let [replies (handle-markread ["#test"] alice components)] + (is (string/includes? (first replies) "2026-04-22T10:00:00.000Z")))))) + (defn -main [& _args] (binding [*out* *err*] |
