diff --git a/.gitignore b/.gitignore index cd12225..59113ff 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,5 @@ anchor.db dist emails/*.html mailship.db +test-mailship.db dist/ diff --git a/package.json b/package.json index 9034745..9b9d5ea 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,9 @@ "check": "tsc --noEmit && eslint src", "format": "prettier --write \"src/**/*.{ts,js,json,html}\"", "start": "node dist/index.js", - "run-alert": "node dist/run.js" + "run-alert": "node dist/run.js", + "test": "bash test/integration.sh", + "test:server": "bash test/integration.sh --server-only" }, "devDependencies": { "@eslint/js": "^9.35.0", diff --git a/src/database.ts b/src/database.ts index f068ee5..b50fe56 100644 --- a/src/database.ts +++ b/src/database.ts @@ -6,7 +6,8 @@ import { instrument } from 'succinct-async' import { now } from '@welshman/lib' import type { Subscription } from './alert.js' -const db = new sqlite3.Database('mailship.db') +const DATA_DIR = process.env.DATA_DIR || '.' +const db = new sqlite3.Database(DATA_DIR + '/mailship.db') type Param = number | string | boolean diff --git a/src/server.ts b/src/server.ts index 335ff3a..c4efb0d 100644 --- a/src/server.ts +++ b/src/server.ts @@ -52,7 +52,22 @@ const addRoute = (method: 'get' | 'post' | 'delete', path: string, handler: Hand } addRoute('get', '/', async (req: Request, res: Response) => { - res.send(await render('../web/dist/index.html')) + try { + const {existsSync} = await import('fs') + const webIndex = new URL('../web/dist/index.html', import.meta.url) + if (existsSync(webIndex)) { + return res.send(await render('../web/dist/index.html')) + } + } catch { + // Fall through to JSON + } + + res.json({ + name: 'Mailship', + description: 'Email notification server for Nostr', + pubkey: await appSigner.getPubkey(), + software: 'https://gitea.coracle.social/mplorentz/mailship', + }) }) // Subscribe to email digests diff --git a/test/integration.sh b/test/integration.sh new file mode 100755 index 0000000..88dcc3a --- /dev/null +++ b/test/integration.sh @@ -0,0 +1,223 @@ +#!/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 + +# Generate a random secret for the test +SECRET="$(openssl rand -hex 32)" + +# Export env vars for the server +export MAILSHIP_SECRET="$SECRET" +export MAILSHIP_NAME="Mailship Test" +export MAILSHIP_URL="$BASE_URL" +export BASE_URL="$BASE_URL" +export POSTMARK_API_KEY="test" +export POSTMARK_SENDER_ADDRESS="test@test.com" +export DEFAULT_RELAYS="wss://relay.damus.io" +export INDEXER_RELAYS="wss://purplepag.es" +export SEARCH_RELAYS="wss://relay.nostr.band" +export PORT="$PORT" +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" + +# Test 2: Register subscription +echo "" +echo "2. Register subscription" +REG=$(curl -s "$BASE_URL/subscription/email" -X POST -H "Content-Type: application/json" \ + -d '{"email":"test@example.com","frequency":"daily","pubkey":"abc123"}') + +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 + +# Test 3: Confirm subscription +echo "" +echo "3. Confirm subscription" +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 + +# Test 4: Check SQLite state +echo "" +echo "4. Database state" +CONFIRMED=$(sqlite3 "$DB_PATH" "SELECT confirmed_at FROM subscriptions WHERE email='test@example.com';" 2>/dev/null) +if [ -n "$CONFIRMED" ] && [ "$CONFIRMED" -gt 0 ]; then + pass "Subscription confirmed in DB" +else + fail "Subscription not confirmed in DB" +fi + +# Test 5: Push event to notify endpoint +echo "" +echo "5. Push event via notify" +SUB_ID=$(sqlite3 "$DB_PATH" "SELECT id FROM subscriptions WHERE email='test@example.com';" 2>/dev/null) + +# 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 + +# Test 6: Dedup +echo "" +echo "6. Dedup" +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 + +# Test 7: 404 for nonexistent subscription +echo "" +echo "7. 404 for nonexistent subscription" +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" + +# Test 8: Unsubscribe +echo "" +echo "8. Unsubscribe" +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 + +# Test 9: Notify after unsubscribe returns 404 +echo "" +echo "9. No push after unsubscribe" +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 + +# Test 10: Delete subscription +echo "" +echo "10. Delete subscription" +DELETE=$(curl -s "$BASE_URL/subscription/$KEY" -X DELETE 2>&1) +check_field "Delete returns ok" "$DELETE" "ok" "True" + +# Summary +echo "" +echo "=========================================" +echo " RESULTS: $PASS passed, $FAIL failed" +echo "=========================================" + +if [ "$FAIL" -gt 0 ]; then + exit 1 +fi +exit 0 \ No newline at end of file