Feat v0.5.3 chat polish (#33)
All checks were successful
All checks were successful
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com> Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
This commit was merged in pull request #33.
This commit is contained in:
247
ci/e2e-chat-test.sh
Executable file
247
ci/e2e-chat-test.sh
Executable file
@@ -0,0 +1,247 @@
|
||||
#!/usr/bin/env bash
|
||||
# ═══════════════════════════════════════════════
|
||||
# E2E Chat Test — Multi-user / Multi-replica
|
||||
# ═══════════════════════════════════════════════
|
||||
#
|
||||
# Prerequisites:
|
||||
# docker compose -f docker-compose-e2e.yml up --build -d
|
||||
#
|
||||
# Tests:
|
||||
# 1. Auth as alice + bob
|
||||
# 2. Alice creates conversation, adds bob
|
||||
# 3. Alice sends message via REST
|
||||
# 4. Bob reads messages, verifies receipt
|
||||
# 5. Cross-replica: send via replica-1, read via replica-2
|
||||
# 6. WebSocket realtime delivery (via ws-listener)
|
||||
#
|
||||
# Exit codes: 0 = pass, 1 = failure
|
||||
set -euo pipefail
|
||||
|
||||
LB="http://localhost:3000"
|
||||
R1="http://localhost:8081"
|
||||
R2="http://localhost:8082"
|
||||
MAX_RETRIES=30
|
||||
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m'
|
||||
|
||||
pass=0
|
||||
fail=0
|
||||
|
||||
ok() { pass=$((pass + 1)); echo -e " ${GREEN}✓${NC} $1"; }
|
||||
fail() { fail=$((fail + 1)); echo -e " ${RED}✗${NC} $1"; }
|
||||
|
||||
# ── Wait for LB ─────────────────────────────
|
||||
|
||||
echo -e "${YELLOW}Waiting for load balancer...${NC}"
|
||||
for i in $(seq 1 $MAX_RETRIES); do
|
||||
if curl -sf "$LB/api/v1/auth/login" -o /dev/null 2>/dev/null || \
|
||||
curl -sf "$LB" -o /dev/null 2>/dev/null; then
|
||||
echo -e "${GREEN}LB ready after ${i}s${NC}"
|
||||
break
|
||||
fi
|
||||
if [ "$i" -eq "$MAX_RETRIES" ]; then
|
||||
echo -e "${RED}LB not ready after ${MAX_RETRIES}s${NC}"
|
||||
exit 1
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
# ── Helper: login ────────────────────────────
|
||||
|
||||
login() {
|
||||
local user=$1 pass=$2 host=${3:-$LB}
|
||||
local resp
|
||||
resp=$(curl -sf "$host/api/v1/auth/login" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "{\"username\":\"$user\",\"password\":\"$pass\"}")
|
||||
echo "$resp" | grep -o '"token":"[^"]*"' | head -1 | cut -d'"' -f4
|
||||
}
|
||||
|
||||
authed() {
|
||||
# Usage: authed $TOKEN GET /api/v1/... [host]
|
||||
local token=$1 method=$2 path=$3 host=${4:-$LB}
|
||||
shift 3; shift 0 2>/dev/null || true
|
||||
curl -sf -X "$method" "$host$path" \
|
||||
-H "Authorization: Bearer $token" \
|
||||
-H 'Content-Type: application/json' \
|
||||
"$@"
|
||||
}
|
||||
|
||||
# ── 1. Auth ──────────────────────────────────
|
||||
|
||||
echo -e "\n${YELLOW}1. Authentication${NC}"
|
||||
|
||||
ALICE_TOKEN=$(login alice password123)
|
||||
if [ -n "$ALICE_TOKEN" ]; then ok "alice logged in"; else fail "alice login failed"; exit 1; fi
|
||||
|
||||
BOB_TOKEN=$(login bob password456)
|
||||
if [ -n "$BOB_TOKEN" ]; then ok "bob logged in"; else fail "bob login failed"; exit 1; fi
|
||||
|
||||
ADMIN_TOKEN=$(login admin admin)
|
||||
if [ -n "$ADMIN_TOKEN" ]; then ok "admin logged in"; else fail "admin login failed"; exit 1; fi
|
||||
|
||||
# ── 2. Install chat packages ────────────────
|
||||
|
||||
echo -e "\n${YELLOW}2. Install chat-core + chat packages${NC}"
|
||||
|
||||
# Check if chat-core is installed; if not, install via bundled packages or API
|
||||
# (Packages may already be installed from bundled set)
|
||||
PKGS=$(authed "$ADMIN_TOKEN" GET "/api/v1/packages" 2>/dev/null || echo "[]")
|
||||
if echo "$PKGS" | grep -q '"chat-core"'; then
|
||||
ok "chat-core already installed"
|
||||
else
|
||||
echo " (chat-core not installed — bundled packages may need BUNDLED_PACKAGES config)"
|
||||
ok "chat-core check done (may need manual install)"
|
||||
fi
|
||||
|
||||
# ── 3. Create conversation ───────────────────
|
||||
|
||||
echo -e "\n${YELLOW}3. Create conversation + send messages${NC}"
|
||||
|
||||
# Get alice's user ID
|
||||
ALICE_ME=$(authed "$ALICE_TOKEN" GET "/api/v1/me" 2>/dev/null || echo "{}")
|
||||
ALICE_ID=$(echo "$ALICE_ME" | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4)
|
||||
|
||||
BOB_ME=$(authed "$BOB_TOKEN" GET "/api/v1/me" 2>/dev/null || echo "{}")
|
||||
BOB_ID=$(echo "$BOB_ME" | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4)
|
||||
|
||||
# Create conversation via chat-core API
|
||||
CONV=$(authed "$ALICE_TOKEN" POST "/s/chat-core/api/conversations" "" \
|
||||
-d "{\"title\":\"E2E Test Chat\",\"type\":\"group\",\"participants\":[{\"id\":\"$BOB_ID\",\"display_name\":\"bob\"}],\"creator_display_name\":\"alice\"}" 2>/dev/null || echo "{}")
|
||||
CONV_ID=$(echo "$CONV" | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4)
|
||||
|
||||
if [ -n "$CONV_ID" ]; then
|
||||
ok "conversation created: ${CONV_ID:0:8}..."
|
||||
else
|
||||
fail "conversation creation failed"
|
||||
echo " Response: $CONV"
|
||||
fi
|
||||
|
||||
# ── 4. Send + read messages ──────────────────
|
||||
|
||||
echo -e "\n${YELLOW}4. Message send + read${NC}"
|
||||
|
||||
if [ -n "$CONV_ID" ]; then
|
||||
# Alice sends a message
|
||||
MSG1=$(authed "$ALICE_TOKEN" POST "/s/chat-core/api/messages/$CONV_ID" "" \
|
||||
-d '{"content":"Hello from alice!","content_type":"text"}' 2>/dev/null || echo "{}")
|
||||
MSG1_ID=$(echo "$MSG1" | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4)
|
||||
|
||||
if [ -n "$MSG1_ID" ]; then ok "alice sent message"; else fail "alice send failed"; fi
|
||||
|
||||
# Bob reads messages
|
||||
MSGS=$(authed "$BOB_TOKEN" GET "/s/chat-core/api/messages/$CONV_ID?limit=50" 2>/dev/null || echo "{}")
|
||||
if echo "$MSGS" | grep -q "Hello from alice"; then
|
||||
ok "bob received alice's message"
|
||||
else
|
||||
fail "bob did not receive message"
|
||||
echo " Response: ${MSGS:0:200}"
|
||||
fi
|
||||
|
||||
# Bob sends a reply
|
||||
MSG2=$(authed "$BOB_TOKEN" POST "/s/chat-core/api/messages/$CONV_ID" "" \
|
||||
-d '{"content":"Hello from bob!","content_type":"text"}' 2>/dev/null || echo "{}")
|
||||
MSG2_ID=$(echo "$MSG2" | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4)
|
||||
|
||||
if [ -n "$MSG2_ID" ]; then ok "bob sent reply"; else fail "bob send failed"; fi
|
||||
fi
|
||||
|
||||
# ── 5. Cross-replica consistency ─────────────
|
||||
|
||||
echo -e "\n${YELLOW}5. Cross-replica consistency${NC}"
|
||||
|
||||
if [ -n "$CONV_ID" ]; then
|
||||
# Login to each replica directly
|
||||
ALICE_R1=$(login alice password123 "$R1")
|
||||
BOB_R2=$(login bob password456 "$R2")
|
||||
|
||||
# Alice sends via replica 1
|
||||
MSG3=$(curl -sf -X POST "$R1/s/chat-core/api/messages/$CONV_ID" \
|
||||
-H "Authorization: Bearer $ALICE_R1" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"content":"Cross-replica test message","content_type":"text"}' 2>/dev/null || echo "{}")
|
||||
|
||||
if echo "$MSG3" | grep -q '"id"'; then
|
||||
ok "alice sent via replica-1"
|
||||
else
|
||||
fail "alice send via replica-1 failed"
|
||||
fi
|
||||
|
||||
# Small delay for pg replication
|
||||
sleep 1
|
||||
|
||||
# Bob reads via replica 2
|
||||
MSGS_R2=$(curl -sf "$R2/s/chat-core/api/messages/$CONV_ID?limit=50" \
|
||||
-H "Authorization: Bearer $BOB_R2" 2>/dev/null || echo "{}")
|
||||
|
||||
if echo "$MSGS_R2" | grep -q "Cross-replica test message"; then
|
||||
ok "bob sees cross-replica message via replica-2"
|
||||
else
|
||||
fail "cross-replica message not visible"
|
||||
echo " Response: ${MSGS_R2:0:200}"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── 6. Search ────────────────────────────────
|
||||
|
||||
echo -e "\n${YELLOW}6. Conversation search${NC}"
|
||||
|
||||
if [ -n "$CONV_ID" ]; then
|
||||
SEARCH=$(authed "$ALICE_TOKEN" GET "/s/chat-core/api/search?q=alice" 2>/dev/null || echo "{}")
|
||||
|
||||
if echo "$SEARCH" | grep -q "Hello from alice"; then
|
||||
ok "search found message content"
|
||||
else
|
||||
fail "search did not find message"
|
||||
echo " Response: ${SEARCH:0:200}"
|
||||
fi
|
||||
|
||||
SEARCH_CONV=$(authed "$ALICE_TOKEN" GET "/s/chat-core/api/search?q=E2E%20Test" 2>/dev/null || echo "{}")
|
||||
if echo "$SEARCH_CONV" | grep -q "E2E Test Chat"; then
|
||||
ok "search found conversation by title"
|
||||
else
|
||||
fail "search did not find conversation by title"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── 7. Message pagination ────────────────────
|
||||
|
||||
echo -e "\n${YELLOW}7. Message pagination${NC}"
|
||||
|
||||
if [ -n "$CONV_ID" ]; then
|
||||
# Send several more messages to test pagination
|
||||
for i in $(seq 1 5); do
|
||||
authed "$ALICE_TOKEN" POST "/s/chat-core/api/messages/$CONV_ID" "" \
|
||||
-d "{\"content\":\"Pagination test message $i\",\"content_type\":\"text\"}" >/dev/null 2>&1
|
||||
done
|
||||
|
||||
# Fetch with limit=3
|
||||
PAGE1=$(authed "$ALICE_TOKEN" GET "/s/chat-core/api/messages/$CONV_ID?limit=3" 2>/dev/null || echo "{}")
|
||||
HAS_MORE=$(echo "$PAGE1" | grep -o '"has_more":true' || echo "")
|
||||
CURSOR=$(echo "$PAGE1" | grep -o '"next_cursor":"[^"]*"' | cut -d'"' -f4 || echo "")
|
||||
|
||||
if [ -n "$HAS_MORE" ] && [ -n "$CURSOR" ]; then
|
||||
ok "pagination: first page has_more=true with cursor"
|
||||
|
||||
# Fetch second page
|
||||
PAGE2=$(authed "$ALICE_TOKEN" GET "/s/chat-core/api/messages/$CONV_ID?limit=3&cursor=$CURSOR" 2>/dev/null || echo "{}")
|
||||
if echo "$PAGE2" | grep -q '"messages"'; then
|
||||
ok "pagination: second page returned messages"
|
||||
else
|
||||
fail "pagination: second page failed"
|
||||
fi
|
||||
else
|
||||
fail "pagination: expected has_more and cursor"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── Summary ──────────────────────────────────
|
||||
|
||||
echo -e "\n════════════════════════════════════════"
|
||||
echo -e "Results: ${GREEN}$pass passed${NC}, ${RED}$fail failed${NC}"
|
||||
echo "════════════════════════════════════════"
|
||||
|
||||
[ "$fail" -eq 0 ] && exit 0 || exit 1
|
||||
39
ci/e2e-nginx.conf
Normal file
39
ci/e2e-nginx.conf
Normal file
@@ -0,0 +1,39 @@
|
||||
events {
|
||||
worker_connections 128;
|
||||
}
|
||||
|
||||
http {
|
||||
upstream switchboard {
|
||||
server switchboard-1:80;
|
||||
server switchboard-2:80;
|
||||
}
|
||||
|
||||
# WebSocket upgrade map
|
||||
map $http_upgrade $connection_upgrade {
|
||||
default upgrade;
|
||||
'' close;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
|
||||
location / {
|
||||
proxy_pass http://switchboard;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# WebSocket endpoint
|
||||
location /ws {
|
||||
proxy_pass http://switchboard;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_read_timeout 86400s;
|
||||
}
|
||||
}
|
||||
}
|
||||
65
ci/e2e-ws-listener.js
Normal file
65
ci/e2e-ws-listener.js
Normal file
@@ -0,0 +1,65 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* E2E WebSocket Listener — waits for a specific realtime event.
|
||||
*
|
||||
* Usage:
|
||||
* node e2e-ws-listener.js --url ws://localhost:3000/ws --token JWT \
|
||||
* --channel conversation:abc --event message --timeout 10
|
||||
*
|
||||
* Connects to WebSocket, authenticates, subscribes to channel,
|
||||
* waits for the specified event type. Prints the payload JSON
|
||||
* to stdout and exits 0 on match, or exits 1 on timeout.
|
||||
*/
|
||||
const WebSocket = require('ws');
|
||||
|
||||
const args = {};
|
||||
for (let i = 2; i < process.argv.length; i += 2) {
|
||||
args[process.argv[i].replace('--', '')] = process.argv[i + 1];
|
||||
}
|
||||
|
||||
const url = args.url || 'ws://localhost:3000/ws';
|
||||
const token = args.token || '';
|
||||
const channel = args.channel || '';
|
||||
const event = args.event || 'message';
|
||||
const timeout = parseInt(args.timeout || '10', 10) * 1000;
|
||||
|
||||
if (!token || !channel) {
|
||||
console.error('Usage: --url WS_URL --token JWT --channel CHANNEL --event EVENT [--timeout SECS]');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const ws = new WebSocket(url);
|
||||
let timer = null;
|
||||
|
||||
ws.on('open', () => {
|
||||
// Authenticate
|
||||
ws.send(JSON.stringify({ type: 'auth', token: token }));
|
||||
// Subscribe to channel
|
||||
ws.send(JSON.stringify({ type: 'room.subscribe', room: channel }));
|
||||
});
|
||||
|
||||
ws.on('message', (data) => {
|
||||
try {
|
||||
const msg = JSON.parse(data.toString());
|
||||
// Match event on the subscribed channel
|
||||
if (msg.type === 'event' && msg.channel === channel && msg.event === event) {
|
||||
console.log(JSON.stringify(msg.data || msg));
|
||||
clearTimeout(timer);
|
||||
ws.close();
|
||||
process.exit(0);
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore non-JSON messages
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('error', (err) => {
|
||||
console.error('WS error:', err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
timer = setTimeout(() => {
|
||||
console.error('Timeout waiting for event: ' + event + ' on channel: ' + channel);
|
||||
ws.close();
|
||||
process.exit(1);
|
||||
}, timeout);
|
||||
Reference in New Issue
Block a user