summaryrefslogtreecommitdiff
path: root/tests
diff options
context:
space:
mode:
authorEuAndreh <eu@euandre.org>2026-04-23 10:03:07 -0300
committerEuAndreh <eu@euandre.org>2026-04-23 10:03:07 -0300
commitd879cbae489b4695c581c16d5659cadd100bd3c1 (patch)
tree1aeb7ea85206d8346ccd366eb0bf22ac6a1d92e4 /tests
parentm (diff)
downloadpapod-d879cbae489b4695c581c16d5659cadd100bd3c1.tar.gz
papod-d879cbae489b4695c581c16d5659cadd100bd3c1.tar.xz
m
Diffstat (limited to 'tests')
-rwxr-xr-xtests/acceptance.sh59
-rw-r--r--tests/integration.clj302
-rw-r--r--tests/unit.clj348
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*]