2026-08-18 16:35:20 +00:00
#!/usr/bin/env bash
# Integration test for Mailship
#
# Starts the server, runs through the full E2E flow, cleans up.
# Exits 0 on success, 1 on failure.
#
# Usage: ./test/integration.sh [--server-only]
# --server-only: just start the server (for manual testing)
set -euo pipefail
SCRIPT_DIR = " $( cd " $( dirname " $0 " ) " && pwd ) "
PROJECT_DIR = " $( cd " $SCRIPT_DIR /.. " && pwd ) "
PORT = " ${ PORT :- 4741 } "
BASE_URL = " http://localhost: $PORT "
PASS = 0
FAIL = 0
GREEN = '\033[0;32m'
RED = '\033[0;31m'
NC = '\033[0m'
cleanup( ) {
echo ""
echo "=== Cleaning up ==="
kill " $SERVER_PID " 2>/dev/null || true
wait " $SERVER_PID " 2>/dev/null || true
rm -rf " $PROJECT_DIR /test-data "
echo "Done."
}
trap cleanup EXIT
# Ensure we have a fresh build
cd " $PROJECT_DIR "
if [ ! -d "dist" ] ; then
echo "Building project..."
npx tsc
fi
2026-09-14 17:04:26 +00:00
# Generate secrets for the test: one for the server, one for the client
SERVER_SECRET = " $( openssl rand -hex 32) "
CLIENT_SECRET = " $( openssl rand -hex 32) "
# Derive the client pubkey so we can look up subscriptions later
CLIENT_PUBKEY = $( node -e "
import { Nip01Signer} from '@welshman/signer' ;
const s = Nip01Signer.fromSecret( '$CLIENT_SECRET' ) ;
s.getPubkey( ) .then( p = > console.log( p) ) ;
" )
echo " Client pubkey: $CLIENT_PUBKEY "
# Helper to build a NIP-98 auth header
nip98_auth( ) {
local url = " $1 " method = " $2 " body = " ${ 3 :- } "
if [ -n " $body " ] ; then
node " $PROJECT_DIR /script/nip98-auth-header.mjs " " $CLIENT_SECRET " " $url " " $method " " $body "
else
node " $PROJECT_DIR /script/nip98-auth-header.mjs " " $CLIENT_SECRET " " $url " " $method "
fi
}
2026-08-18 16:35:20 +00:00
# Export env vars for the server
2026-09-14 17:04:26 +00:00
export MAILSHIP_SECRET = " $SERVER_SECRET "
2026-08-18 16:35:20 +00:00
export MAILSHIP_NAME = "Mailship Test"
export MAILSHIP_URL = " $BASE_URL "
export BASE_URL = " $BASE_URL "
Align README, docker-compose, .env.template with SMTP mailer
Replace all Postmark references (POSTMARK_API_KEY, POSTMARK_SENDER_ADDRESS)
with SMTP configuration (SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASSWORD,
SMTP_FROM) across documentation, deployment config, template, and test.
- README.md: architecture diagram (Postmark → SMTP) and configuration table
- docker-compose.yml: POSTMARK_* env vars replaced with SMTP_* required vars
- .env.template: POSTMARK_* entries replaced with SMTP_* entries
- test/integration.sh: POSTMARK_* test exports replaced with SMTP_* test values
2026-09-10 15:30:50 +00:00
export SMTP_HOST = "localhost"
export SMTP_PORT = "587"
export SMTP_USER = "test@test.com"
export SMTP_PASSWORD = "test"
export SMTP_FROM = "test@test.com"
2026-08-18 16:35:20 +00:00
export DEFAULT_RELAYS = "wss://relay.damus.io"
export INDEXER_RELAYS = "wss://purplepag.es"
export SEARCH_RELAYS = "wss://relay.nostr.band"
export PORT = " $PORT "
2026-09-14 17:07:16 +00:00
export CORS_ORIGIN = " $BASE_URL "
2026-08-18 16:35:20 +00:00
export DATA_DIR = " $PROJECT_DIR /test-data "
mkdir -p " $DATA_DIR "
DB_PATH = " $DATA_DIR /mailship.db "
echo " === Starting server on port $PORT === "
node dist/index.js &
SERVER_PID = $!
sleep 3
if ! kill -0 " $SERVER_PID " 2>/dev/null; then
echo -e " ${ RED } Server failed to start ${ NC } "
exit 1
fi
echo " Server PID: $SERVER_PID "
if [ " ${ 1 :- } " = "--server-only" ] ; then
echo ""
echo " Server running at $BASE_URL "
echo " DB at $DB_PATH "
echo "Press Ctrl+C to stop."
wait " $SERVER_PID "
exit 0
fi
echo ""
echo "========================================="
echo " INTEGRATION TESTS"
echo "========================================="
echo ""
pass( ) {
PASS = $(( PASS + 1 ))
echo -e " ${ GREEN } ✓ ${ NC } $1 "
}
fail( ) {
FAIL = $(( FAIL + 1 ))
echo -e " ${ RED } ✗ ${ NC } $1 "
}
# Helper: check that a JSON field equals expected value
check_field( ) {
local label = " $1 "
local json = " $2 "
local field = " $3 "
local expected = " $4 "
local actual
actual = $( echo " $json " | python3 -c " import sys,json; print(json.load(sys.stdin).get(' $field ','')) " 2>/dev/null)
if [ " $actual " = " $expected " ] ; then
pass " $label "
else
fail " $label (expected $field = $expected , got $actual ) "
fi
}
# Test 1: Health check
echo "1. Health check"
HEALTH = $( curl -s " $BASE_URL / " )
check_field "Root endpoint returns Mailship" " $HEALTH " "name" "Mailship"
2026-09-14 17:04:26 +00:00
# Test 2: Register subscription with NIP-98 auth
2026-08-18 16:35:20 +00:00
echo ""
2026-09-14 17:04:26 +00:00
echo "2. Register subscription (NIP-98 auth)"
AUTH_PUT = $( nip98_auth " $BASE_URL /subscription/email " PUT '{"email":"test@example.com","frequency":"daily"}' )
2026-09-03 15:47:21 +00:00
REG = $( curl -s " $BASE_URL /subscription/email " -X PUT -H "Content-Type: application/json" \
2026-09-14 17:04:26 +00:00
-H " Authorization: $AUTH_PUT " \
-d '{"email":"test@example.com","frequency":"daily"}' )
2026-08-18 16:35:20 +00:00
KEY = $( echo " $REG " | python3 -c "import sys,json; print(json.load(sys.stdin).get('key',''))" 2>/dev/null)
CALLBACK = $( echo " $REG " | python3 -c "import sys,json; print(json.load(sys.stdin).get('callback',''))" 2>/dev/null)
if [ -n " $KEY " ] && [ -n " $CALLBACK " ] ; then
pass "Registration returns key and callback"
else
fail " Registration missing key or callback (got: $REG ) "
fi
2026-09-14 17:04:26 +00:00
# Test 3: PUT without auth returns 401
echo ""
echo "3. PUT without auth returns 401"
NO_AUTH = $( curl -s " $BASE_URL /subscription/email " -X PUT -H "Content-Type: application/json" \
-d '{"email":"test@example.com","frequency":"daily"}' )
check_field "No-auth PUT returns 401" " $NO_AUTH " "error" "NIP-98 authorization required"
# Test 4: GET /subscription/email with auth
2026-08-18 16:35:20 +00:00
echo ""
2026-09-14 17:04:26 +00:00
echo "4. GET subscription with auth"
AUTH_GET = $( nip98_auth " $BASE_URL /subscription/email " GET)
GET_RESP = $( curl -s " $BASE_URL /subscription/email " \
-H " Authorization: $AUTH_GET " )
check_field "GET returns our email" " $GET_RESP " "email" "test@example.com"
check_field "GET returns frequency" " $GET_RESP " "frequency" "daily"
# Test 5: GET without auth returns 401
echo ""
echo "5. GET without auth returns 401"
GET_NO_AUTH = $( curl -s " $BASE_URL /subscription/email " )
check_field "No-auth GET returns 401" " $GET_NO_AUTH " "error" "NIP-98 authorization required"
# Test 6: Confirm subscription
echo ""
echo "6. Confirm subscription"
2026-08-18 16:35:20 +00:00
CONFIRM = $( curl -s " $BASE_URL /confirm?token= $KEY " 2>& 1)
if echo " $CONFIRM " | grep -qi "success" ; then
pass "Confirmation page shows success"
else
fail "Confirmation page doesn't show success"
fi
2026-09-14 17:04:26 +00:00
# Test 7: Check SQLite state
2026-08-18 16:35:20 +00:00
echo ""
2026-09-14 17:04:26 +00:00
echo "7. Database state"
CONFIRMED = $( sqlite3 " $DB_PATH " " SELECT confirmed_at FROM subscriptions WHERE pubkey=' $CLIENT_PUBKEY '; " 2>/dev/null)
2026-08-18 16:35:20 +00:00
if [ -n " $CONFIRMED " ] && [ " $CONFIRMED " -gt 0 ] ; then
pass "Subscription confirmed in DB"
else
fail "Subscription not confirmed in DB"
fi
2026-09-14 17:04:26 +00:00
# Test 8: Push event to notify endpoint
2026-08-18 16:35:20 +00:00
echo ""
2026-09-14 17:04:26 +00:00
echo "8. Push event via notify"
SUB_ID = $( sqlite3 " $DB_PATH " " SELECT id FROM subscriptions WHERE pubkey=' $CLIENT_PUBKEY '; " 2>/dev/null)
2026-08-18 16:35:20 +00:00
# Fetch a real event from a relay
EVENT_ID = $( nak req -k 1 -l 1 wss://relay.primal.net 2>/dev/null | head -1 | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['id'])" 2>/dev/null)
if [ -z " $EVENT_ID " ] ; then
# Try another relay
EVENT_ID = $( nak req -k 1 -l 1 wss://nos.lol 2>/dev/null | head -1 | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['id'])" 2>/dev/null)
fi
if [ -z " $EVENT_ID " ] ; then
fail "Could not fetch event from relay (network issue?)"
else
NOTIFY = $( curl -s " $BASE_URL /notify/ $SUB_ID " -X POST -H "Content-Type: application/json" \
-d " {\"id\":\" $EVENT_ID \",\"relay\":\"wss://relay.primal.net\"} " )
check_field "Event stored in DB" " $NOTIFY " "stored" "True"
fi
2026-09-14 17:04:26 +00:00
# Test 9: Dedup
2026-08-18 16:35:20 +00:00
echo ""
2026-09-14 17:04:26 +00:00
echo "9. Dedup"
2026-08-18 16:35:20 +00:00
if [ -n " $EVENT_ID " ] ; then
DEDUP = $( curl -s " $BASE_URL /notify/ $SUB_ID " -X POST -H "Content-Type: application/json" \
-d " {\"id\":\" $EVENT_ID \",\"relay\":\"wss://relay.primal.net\"} " )
check_field "Duplicate event rejected" " $DEDUP " "stored" "False"
fi
2026-09-14 17:04:26 +00:00
# Test 10: 404 for nonexistent subscription
2026-08-18 16:35:20 +00:00
echo ""
2026-09-14 17:04:26 +00:00
echo "10. 404 for nonexistent subscription"
2026-08-18 16:35:20 +00:00
NOT_FOUND = $( curl -s " $BASE_URL /notify/nonexistent-id " -X POST -H "Content-Type: application/json" \
-d '{"id":"abc","relay":"wss://relay.primal.net"}' )
check_field "Nonexistent subscription returns 404" " $NOT_FOUND " "error" "Subscription not found"
2026-09-14 17:04:26 +00:00
# Test 11: Unsubscribe
2026-08-18 16:35:20 +00:00
echo ""
2026-09-14 17:04:26 +00:00
echo "11. Unsubscribe"
2026-08-18 16:35:20 +00:00
UNSUB = $( curl -s " $BASE_URL /unsubscribe?token= $KEY " )
if echo " $UNSUB " | grep -qi "unsubscribed\|success" ; then
pass "Unsubscribe page renders"
else
fail "Unsubscribe page didn't render"
fi
2026-09-14 17:04:26 +00:00
# Test 12: Notify after unsubscribe returns 404
2026-08-18 16:35:20 +00:00
echo ""
2026-09-14 17:04:26 +00:00
echo "12. No push after unsubscribe"
2026-08-18 16:35:20 +00:00
if [ -n " $EVENT_ID " ] ; then
AFTER_UNSUB = $( curl -s " $BASE_URL /notify/ $SUB_ID " -X POST -H "Content-Type: application/json" \
-d " {\"id\":\" $EVENT_ID \",\"relay\":\"wss://relay.primal.net\"} " )
check_field "Push after unsubscribe returns 404" " $AFTER_UNSUB " "error" "Subscription not active"
fi
2026-09-14 17:04:26 +00:00
# Test 13: Delete subscription with auth
2026-08-18 16:35:20 +00:00
echo ""
2026-09-14 17:04:26 +00:00
echo "13. Delete subscription with NIP-98 auth"
AUTH_DEL = $( nip98_auth " $BASE_URL /subscription/ $KEY " DELETE)
DELETE = $( curl -s " $BASE_URL /subscription/ $KEY " -X DELETE -H " Authorization: $AUTH_DEL " )
2026-08-18 16:35:20 +00:00
check_field "Delete returns ok" " $DELETE " "ok" "True"
2026-09-14 17:04:26 +00:00
# Test 14: Delete without auth returns 401
echo ""
echo "14. Delete without auth returns 401"
DEL_NO_AUTH = $( curl -s " $BASE_URL /subscription/ $KEY " -X DELETE)
check_field "No-auth DELETE returns 401" " $DEL_NO_AUTH " "error" "NIP-98 authorization required"
2026-09-21 14:05:53 +00:00
# Test 15: Re-subscribe after delete reactivates the same row (no duplicate)
# Regression: DELETE tombstones the row; re-subscribing with the SAME pubkey +
# email must reactivate it, not insert a second row (off/on cycle previously
# accumulated one row per cycle).
echo ""
echo "15. Re-subscribe after delete (de-dupe regression)"
OLD_ID = $( sqlite3 " $DB_PATH " " SELECT id FROM subscriptions WHERE pubkey=' $CLIENT_PUBKEY ' AND unsubscribed_at IS NOT NULL ORDER BY created_at DESC LIMIT 1; " 2>/dev/null)
AUTH_RE = $( nip98_auth " $BASE_URL /subscription/email " PUT '{"email":"test@example.com","frequency":"daily"}' )
RE_REG = $( curl -s " $BASE_URL /subscription/email " -X PUT -H "Content-Type: application/json" \
-H " Authorization: $AUTH_RE " \
-d '{"email":"test@example.com","frequency":"daily"}' )
NEW_ID = $( sqlite3 " $DB_PATH " " SELECT id FROM subscriptions WHERE pubkey=' $CLIENT_PUBKEY ' AND unsubscribed_at IS NULL LIMIT 1; " 2>/dev/null)
if [ -n " $OLD_ID " ] && [ " $NEW_ID " = " $OLD_ID " ] ; then
pass "Re-subscribe reactivates the same row"
else
fail " Re-subscribe created a duplicate row (old= $OLD_ID , new= $NEW_ID ) "
fi
TOTAL_ROWS = $( sqlite3 " $DB_PATH " " SELECT COUNT(*) FROM subscriptions WHERE pubkey=' $CLIENT_PUBKEY '; " 2>/dev/null)
if [ " $TOTAL_ROWS " = "1" ] ; then
pass "Exactly one row for this pubkey"
else
fail " Expected 1 row for this pubkey, got $TOTAL_ROWS "
fi
2026-08-18 16:35:20 +00:00
# Summary
echo ""
echo "========================================="
echo " RESULTS: $PASS passed, $FAIL failed "
echo "========================================="
if [ " $FAIL " -gt 0 ] ; then
exit 1
fi
exit 0