| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370 |
- #!/bin/bash
- # test-mail.sh - Test harness for mail-ops
- # Outputs: number of passing test cases
- # Each test prints PASS/FAIL and we count PASSes at the end
- set -uo pipefail
- MAIL_DB="$HOME/.claude/mail.db"
- MAIL_SCRIPT="$(dirname "$0")/mail-db.sh"
- HOOK_SCRIPT="$(dirname "$0")/../../hooks/check-mail.sh"
- # Resolve relative to repo root if needed
- if [ ! -f "$HOOK_SCRIPT" ]; then
- HOOK_SCRIPT="$(cd "$(dirname "$0")/../../.." && pwd)/hooks/check-mail.sh"
- fi
- PASS=0
- FAIL=0
- TOTAL=0
- assert() {
- local name="$1"
- local expected="$2"
- local actual="$3"
- TOTAL=$((TOTAL + 1))
- if [ "$expected" = "$actual" ]; then
- echo "PASS: $name"
- PASS=$((PASS + 1))
- else
- echo "FAIL: $name (expected='$expected', actual='$actual')"
- FAIL=$((FAIL + 1))
- fi
- }
- assert_contains() {
- local name="$1"
- local needle="$2"
- local haystack="$3"
- TOTAL=$((TOTAL + 1))
- if echo "$haystack" | grep -qF "$needle"; then
- echo "PASS: $name"
- PASS=$((PASS + 1))
- else
- echo "FAIL: $name (expected to contain '$needle')"
- FAIL=$((FAIL + 1))
- fi
- }
- assert_not_empty() {
- local name="$1"
- local value="$2"
- TOTAL=$((TOTAL + 1))
- if [ -n "$value" ]; then
- echo "PASS: $name"
- PASS=$((PASS + 1))
- else
- echo "FAIL: $name (was empty)"
- FAIL=$((FAIL + 1))
- fi
- }
- assert_empty() {
- local name="$1"
- local value="$2"
- TOTAL=$((TOTAL + 1))
- if [ -z "$value" ]; then
- echo "PASS: $name"
- PASS=$((PASS + 1))
- else
- echo "FAIL: $name (expected empty, got '$value')"
- FAIL=$((FAIL + 1))
- fi
- }
- assert_exit_code() {
- local name="$1"
- local expected="$2"
- local actual="$3"
- TOTAL=$((TOTAL + 1))
- if [ "$expected" = "$actual" ]; then
- echo "PASS: $name"
- PASS=$((PASS + 1))
- else
- echo "FAIL: $name (exit code expected=$expected, actual=$actual)"
- FAIL=$((FAIL + 1))
- fi
- }
- # --- Setup: clean slate ---
- rm -f "$MAIL_DB"
- echo "=== Basic Operations ==="
- # T1: Init creates database
- bash "$MAIL_SCRIPT" init >/dev/null 2>&1
- assert "init creates database" "true" "$([ -f "$MAIL_DB" ] && echo true || echo false)"
- # T2: Count on empty inbox
- result=$(bash "$MAIL_SCRIPT" count)
- assert "empty inbox count is 0" "0" "$result"
- # T3: Send a message
- result=$(bash "$MAIL_SCRIPT" send "test-project" "Hello" "World" 2>&1)
- assert_contains "send succeeds" "Sent to test-project" "$result"
- # T4: Count after send (we're in claude-mods, sent to test-project)
- result=$(bash "$MAIL_SCRIPT" count)
- assert "count still 0 for sender project" "0" "$result"
- # T5: Send to self
- result=$(bash "$MAIL_SCRIPT" send "claude-mods" "Self mail" "Testing self-send" 2>&1)
- assert_contains "self-send succeeds" "Sent to claude-mods" "$result"
- # T6: Count after self-send
- result=$(bash "$MAIL_SCRIPT" count)
- assert "count is 1 after self-send" "1" "$result"
- # T7: Unread shows message
- result=$(bash "$MAIL_SCRIPT" unread)
- assert_contains "unread shows subject" "Self mail" "$result"
- # T8: Read marks as read
- bash "$MAIL_SCRIPT" read >/dev/null 2>&1
- result=$(bash "$MAIL_SCRIPT" count)
- assert "count is 0 after read" "0" "$result"
- # T9: List shows read messages
- result=$(bash "$MAIL_SCRIPT" list)
- assert_contains "list shows read status" "read" "$result"
- # T10: Projects lists known projects
- result=$(bash "$MAIL_SCRIPT" projects)
- assert_contains "projects lists claude-mods" "claude-mods" "$result"
- assert_contains "projects lists test-project" "test-project" "$result"
- echo ""
- echo "=== Edge Cases ==="
- # T11: Empty body - should fail gracefully
- result=$(bash "$MAIL_SCRIPT" send "target" "subject" "" 2>&1)
- exit_code=$?
- # Empty body should either fail or send empty - document the behavior
- TOTAL=$((TOTAL + 1))
- if [ $exit_code -ne 0 ] || echo "$result" | grep -qiE "error|required|empty"; then
- echo "PASS: empty body rejected or warned"
- PASS=$((PASS + 1))
- else
- echo "FAIL: empty body accepted silently"
- FAIL=$((FAIL + 1))
- fi
- # T12: Missing arguments to send
- result=$(bash "$MAIL_SCRIPT" send 2>&1)
- exit_code=$?
- assert_exit_code "send with no args fails" "1" "$exit_code"
- # T13: SQL injection in subject
- bash "$MAIL_SCRIPT" send "claude-mods" "'; DROP TABLE messages; --" "injection test" >/dev/null 2>&1
- result=$(bash "$MAIL_SCRIPT" count)
- # If table still exists and count works, injection failed (good)
- TOTAL=$((TOTAL + 1))
- if [ -n "$result" ] && [ "$result" -ge 0 ] 2>/dev/null; then
- echo "PASS: SQL injection in subject blocked"
- PASS=$((PASS + 1))
- else
- echo "FAIL: SQL injection may have succeeded"
- FAIL=$((FAIL + 1))
- fi
- # T14: SQL injection in body
- bash "$MAIL_SCRIPT" send "claude-mods" "test" "'); DELETE FROM messages; --" >/dev/null 2>&1
- result=$(bash "$MAIL_SCRIPT" count)
- TOTAL=$((TOTAL + 1))
- if [ -n "$result" ] && [ "$result" -ge 0 ] 2>/dev/null; then
- echo "PASS: SQL injection in body blocked"
- PASS=$((PASS + 1))
- else
- echo "FAIL: SQL injection in body may have succeeded"
- FAIL=$((FAIL + 1))
- fi
- # T15: SQL injection in project name
- bash "$MAIL_SCRIPT" send "'; DROP TABLE messages; --" "test" "injection via project" >/dev/null 2>&1
- result=$(bash "$MAIL_SCRIPT" count)
- TOTAL=$((TOTAL + 1))
- if [ -n "$result" ] && [ "$result" -ge 0 ] 2>/dev/null; then
- echo "PASS: SQL injection in project name blocked"
- PASS=$((PASS + 1))
- else
- echo "FAIL: SQL injection in project name may have succeeded"
- FAIL=$((FAIL + 1))
- fi
- # T16: Special characters in body (newlines, quotes, backslashes)
- bash "$MAIL_SCRIPT" send "claude-mods" "special chars" 'Line1\nLine2 "quoted" and back\\slash' >/dev/null 2>&1
- result=$(bash "$MAIL_SCRIPT" read 2>&1)
- assert_contains "special chars preserved" "special chars" "$result"
- # T17: Very long message body (1000+ chars)
- long_body=$(python3 -c "print('x' * 2000)" 2>/dev/null || printf '%0.s.' $(seq 1 2000))
- bash "$MAIL_SCRIPT" send "claude-mods" "long msg" "$long_body" >/dev/null 2>&1
- result=$(bash "$MAIL_SCRIPT" count)
- assert "long message accepted" "1" "$result"
- bash "$MAIL_SCRIPT" read >/dev/null 2>&1
- # T18: Unicode in subject and body
- bash "$MAIL_SCRIPT" send "claude-mods" "Unicode test" "Hello from Tokyo" >/dev/null 2>&1
- result=$(bash "$MAIL_SCRIPT" read 2>&1)
- assert_contains "unicode in body" "Tokyo" "$result"
- # T19: Read by specific ID
- bash "$MAIL_SCRIPT" send "claude-mods" "ID test" "Read me by ID" >/dev/null 2>&1
- msg_id=$(sqlite3 "$MAIL_DB" "SELECT id FROM messages WHERE subject='ID test' AND read=0 LIMIT 1;")
- result=$(bash "$MAIL_SCRIPT" read "$msg_id" 2>&1)
- assert_contains "read by ID works" "Read me by ID" "$result"
- # T20: Read by invalid ID
- result=$(bash "$MAIL_SCRIPT" read 99999 2>&1)
- assert_empty "read invalid ID returns nothing" "$result"
- echo ""
- echo "=== Hook Tests ==="
- # T21: Hook silent on empty inbox
- bash "$MAIL_SCRIPT" read >/dev/null 2>&1 # clear any unread
- result=$(bash "$HOOK_SCRIPT" 2>&1)
- assert_empty "hook silent when no mail" "$result"
- # T22: Hook shows notification
- bash "$MAIL_SCRIPT" send "claude-mods" "Hook test" "Should trigger hook" >/dev/null 2>&1
- result=$(bash "$HOOK_SCRIPT" 2>&1)
- assert_contains "hook shows MAIL notification" "MAIL" "$result"
- assert_contains "hook shows message count" "1 unread" "$result"
- # T23: Hook with missing database
- backup_db="${MAIL_DB}.testbak"
- mv "$MAIL_DB" "$backup_db"
- result=$(bash "$HOOK_SCRIPT" 2>&1)
- exit_code=$?
- assert_exit_code "hook exits 0 with missing db" "0" "$exit_code"
- assert_empty "hook silent with missing db" "$result"
- mv "$backup_db" "$MAIL_DB"
- echo ""
- echo "=== Cleanup ==="
- # T24: Clear old messages
- bash "$MAIL_SCRIPT" read >/dev/null 2>&1 # mark all as read
- result=$(bash "$MAIL_SCRIPT" clear 0 2>&1)
- assert_contains "clear reports deleted count" "Cleared" "$result"
- # T25: Count after clear
- result=$(bash "$MAIL_SCRIPT" count)
- assert "count 0 after clear" "0" "$result"
- # T26: Help command
- result=$(bash "$MAIL_SCRIPT" help 2>&1)
- assert_contains "help shows usage" "Usage" "$result"
- # T27: Unknown command
- result=$(bash "$MAIL_SCRIPT" nonexistent 2>&1)
- exit_code=$?
- assert_exit_code "unknown command fails" "1" "$exit_code"
- echo ""
- echo "=== Input Validation ==="
- # T28: Non-numeric message ID rejected
- result=$(bash "$MAIL_SCRIPT" read "abc" 2>&1)
- exit_code=$?
- assert_exit_code "non-numeric ID rejected" "1" "$exit_code"
- # T29: SQL injection via message ID
- bash "$MAIL_SCRIPT" send "claude-mods" "id-inject-test" "before injection" >/dev/null 2>&1
- result=$(bash "$MAIL_SCRIPT" read "1 OR 1=1" 2>&1)
- exit_code=$?
- assert_exit_code "SQL injection via ID rejected" "1" "$exit_code"
- # T30: Non-numeric limit in list
- result=$(bash "$MAIL_SCRIPT" list "abc" 2>&1)
- exit_code=$?
- assert_exit_code "non-numeric limit handled" "0" "$exit_code"
- # T31: Non-numeric days in clear
- result=$(bash "$MAIL_SCRIPT" clear "abc" 2>&1)
- assert_contains "non-numeric days handled" "Cleared" "$result"
- # T32: Single quotes in subject preserved
- bash "$MAIL_SCRIPT" read >/dev/null 2>&1 # clear unread
- bash "$MAIL_SCRIPT" send "claude-mods" "it's working" "body with 'quotes'" >/dev/null 2>&1
- result=$(bash "$MAIL_SCRIPT" read 2>&1)
- assert_contains "single quotes in subject" "it's working" "$result"
- # T33: Double quotes in body preserved
- bash "$MAIL_SCRIPT" send "claude-mods" "quotes" 'She said "hello"' >/dev/null 2>&1
- result=$(bash "$MAIL_SCRIPT" read 2>&1)
- assert_contains "double quotes in body" "hello" "$result"
- # T34: Project name with spaces (edge case)
- bash "$MAIL_SCRIPT" send "my project" "spaces" "project name has spaces" >/dev/null 2>&1
- result=$(bash "$MAIL_SCRIPT" projects)
- assert_contains "project with spaces stored" "my project" "$result"
- # T35: Multiple rapid sends
- for i in 1 2 3 4 5; do
- bash "$MAIL_SCRIPT" send "claude-mods" "rapid-$i" "rapid fire test $i" >/dev/null 2>&1
- done
- result=$(bash "$MAIL_SCRIPT" count)
- assert "5 rapid sends all counted" "5" "$result"
- bash "$MAIL_SCRIPT" read >/dev/null 2>&1
- # T36: Init is idempotent
- bash "$MAIL_SCRIPT" init >/dev/null 2>&1
- bash "$MAIL_SCRIPT" init >/dev/null 2>&1
- result=$(bash "$MAIL_SCRIPT" count)
- assert "init idempotent" "0" "$result"
- # T37: Empty subject defaults
- result=$(bash "$MAIL_SCRIPT" send "claude-mods" "" "empty subject body" 2>&1)
- assert_contains "empty subject accepted" "Sent to claude-mods" "$result"
- bash "$MAIL_SCRIPT" read >/dev/null 2>&1
- echo ""
- echo "=== Reply ==="
- # T38: Reply to a message
- bash "$MAIL_SCRIPT" send "claude-mods" "Original msg" "Please reply" >/dev/null 2>&1
- msg_id=$(sqlite3 "$MAIL_DB" "SELECT id FROM messages WHERE subject='Original msg' AND read=0 LIMIT 1;")
- bash "$MAIL_SCRIPT" read "$msg_id" >/dev/null 2>&1
- result=$(bash "$MAIL_SCRIPT" reply "$msg_id" "Here is my reply" 2>&1)
- assert_contains "reply succeeds" "Replied to claude-mods" "$result"
- assert_contains "reply has Re: prefix" "Re: Original msg" "$result"
- # T39: Reply to nonexistent message
- result=$(bash "$MAIL_SCRIPT" reply 99999 "reply to nothing" 2>&1)
- exit_code=$?
- assert_exit_code "reply to nonexistent fails" "1" "$exit_code"
- # T40: Reply with empty body
- result=$(bash "$MAIL_SCRIPT" reply "$msg_id" "" 2>&1)
- exit_code=$?
- assert_exit_code "reply with empty body fails" "1" "$exit_code"
- # T41: Reply with non-numeric ID
- result=$(bash "$MAIL_SCRIPT" reply "abc" "body" 2>&1)
- exit_code=$?
- assert_exit_code "reply with non-numeric ID fails" "1" "$exit_code"
- # Clean up
- bash "$MAIL_SCRIPT" read >/dev/null 2>&1
- echo ""
- echo "=== Performance ==="
- # T38: Hook cooldown - second call within cooldown is silent even with mail
- bash "$MAIL_SCRIPT" send "claude-mods" "cooldown test" "testing cooldown" >/dev/null 2>&1
- # Clear any cooldown files for current PID
- rm -f /tmp/agentmail_check_* 2>/dev/null
- # First call should show mail
- result1=$(bash "$HOOK_SCRIPT" 2>&1)
- assert_contains "hook fires on first call" "MAIL" "$result1"
- # Note: can't easily test cooldown across separate bash invocations since PID changes
- # But we can verify the cooldown file was created
- bash "$MAIL_SCRIPT" read >/dev/null 2>&1
- echo ""
- echo "=== Results ==="
- echo "Passed: $PASS / $TOTAL"
- echo "Failed: $FAIL / $TOTAL"
- echo ""
- echo "$PASS"
|