diff --git a/README.md b/README.md index 51c9fa7..3af5090 100644 --- a/README.md +++ b/README.md @@ -130,8 +130,9 @@ docker run -d \ ## Tests ```sh -pnpm test # Run integration tests -pnpm test:server # Start server for manual testing +pnpm test:unit # Run unit tests (vitest) +pnpm test # Run integration tests (bash E2E) +pnpm test:server # Start server for manual testing ``` ## Forked from Anchor diff --git a/package.json b/package.json index 9ac4de2..306a4b7 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "preview:digest": "node script/render-preview.mjs", "run-alert": "node dist/run.js", "test": "bash test/integration.sh", + "test:unit": "vitest run", "test:server": "bash test/integration.sh --server-only" }, "devDependencies": { @@ -29,7 +30,8 @@ "globals": "^15.15.0", "onchange": "^7.1.0", "prettier": "^3.6.2", - "typescript": "^5.9.2" + "typescript": "^5.9.2", + "vitest": "^5.0.0" }, "dependencies": { "@types/node": "^22.18.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 15e0f81..f444b05 100644 Binary files a/pnpm-lock.yaml and b/pnpm-lock.yaml differ diff --git a/script/checks b/script/checks index 310e1d1..287929d 100755 --- a/script/checks +++ b/script/checks @@ -1,6 +1,7 @@ #!/usr/bin/env bash set -euo pipefail -# mailship CI checks — type-check + lint + build +# mailship CI checks — type-check + lint + build + unit tests pnpm run check -pnpm run build \ No newline at end of file +pnpm run build +pnpm test:unit \ No newline at end of file diff --git a/test/digest-template.test.js b/test/digest-template.test.js deleted file mode 100644 index 47de77f..0000000 --- a/test/digest-template.test.js +++ /dev/null @@ -1,74 +0,0 @@ -#!/usr/bin/env node -// FAILING test: digest.mjml hardcodes #7161FF instead of using {{brandAccent}} -// -// The bug: in src/emails/digest.mjml line 8 and line 22, the CSS for -// .event-item border-left and .footer a color hardcode #7161FF even though -// {{brandAccent}} is passed into the template by mailer.ts and used -// elsewhere (lines 15, 34). When BRAND_ACCENT is customized, the event-item -// border and footer links stay the default purple. -// -// The fix: replace both hardcoded #7161FF values with {{brandAccent}}. - -import { readFileSync } from 'fs'; -import { fileURLToPath } from 'url'; -import { dirname, join } from 'path'; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const templatePath = join(__dirname, '..', 'src', 'emails', 'digest.mjml'); - -let passed = 0; -let failed = 0; - -function assert(label, ok, detail) { - if (ok) { - console.log(` ✓ ${label}`); - passed++; - } else { - console.log(` ✗ ${label} — ${detail || ''}`); - failed++; - } -} - -// Read the MJML template -const source = readFileSync(templatePath, 'utf8'); -const lines = source.split('\n'); - -console.log('1. No hardcoded #7161FF in .event-item or .footer a CSS'); - -// Check .event-item border-left doesn't have #7161FF -const eventItemLineIdx = lines.findIndex(l => l.includes('.event-item')); -const hasEventItemHardcoded = lines.some(l => l.includes('.event-item') && l.includes('#7161FF')); -assert( - '.event-item border-left does NOT hardcode #7161FF', - !hasEventItemHardcoded, - hasEventItemHardcoded ? `Line ${eventItemLineIdx + 1} still has #7161FF: "${lines[eventItemLineIdx].trim()}"` : '' -); - -// Check .footer a color doesn't have #7161FF -const footerAIdx = lines.findIndex(l => l.includes('.footer a')); -const hasFooterHardcoded = lines.some(l => l.includes('.footer a') && l.includes('#7161FF')); -assert( - '.footer a color does NOT hardcode #7161FF', - !hasFooterHardcoded, - hasFooterHardcoded ? `Line ${footerAIdx + 1} still has #7161FF: "${lines[footerAIdx].trim()}"` : '' -); - -// Check .event-item border-left uses {{brandAccent}} -const eventItemLine = lines[eventItemLineIdx]; -assert( - '.event-item border-left uses {{brandAccent}}', - eventItemLine && eventItemLine.includes('{{brandAccent}}'), - eventItemLine ? `Line ${eventItemLineIdx + 1}: "${eventItemLine.trim()}"` : '.event-item line not found' -); - -// Check .footer a color uses {{brandAccent}} -const footerALine = lines[footerAIdx]; -assert( - '.footer a color uses {{brandAccent}}', - footerALine && footerALine.includes('{{brandAccent}}'), - footerALine ? `Line ${footerAIdx + 1}: "${footerALine.trim()}"` : '.footer a line not found' -); - -console.log(''); -console.log(`Results: ${passed} passed, ${failed} failed`); -process.exit(failed > 0 ? 1 : 0); \ No newline at end of file diff --git a/test/digest-template.test.ts b/test/digest-template.test.ts new file mode 100644 index 0000000..6615a58 --- /dev/null +++ b/test/digest-template.test.ts @@ -0,0 +1,33 @@ +import { describe, it, expect } from 'vitest' +import { readFileSync } from 'fs' +import { fileURLToPath } from 'url' +import { dirname, join } from 'path' + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const templatePath = join(__dirname, '..', 'src', 'emails', 'digest.mjml') +const source = readFileSync(templatePath, 'utf8') +const lines = source.split('\n') + +describe('digest.mjml brandAccent usage', () => { + it('.event-item border-left does NOT hardcode #7161FF', () => { + const hasHardcoded = lines.some(l => l.includes('.event-item') && l.includes('#7161FF')) + expect(hasHardcoded).toBe(false) + }) + + it('.footer a color does NOT hardcode #7161FF', () => { + const hasHardcoded = lines.some(l => l.includes('.footer a') && l.includes('#7161FF')) + expect(hasHardcoded).toBe(false) + }) + + it('.event-item border-left uses {{brandAccent}}', () => { + const eventItemLine = lines.find(l => l.includes('.event-item')) + expect(eventItemLine).toBeDefined() + expect(eventItemLine).toContain('{{brandAccent}}') + }) + + it('.footer a color uses {{brandAccent}}', () => { + const footerALine = lines.find(l => l.includes('.footer a')) + expect(footerALine).toBeDefined() + expect(footerALine).toContain('{{brandAccent}}') + }) +}) \ No newline at end of file diff --git a/test/event-arrival-race.test.js b/test/event-arrival-race.test.js deleted file mode 100644 index 8a938c7..0000000 --- a/test/event-arrival-race.test.js +++ /dev/null @@ -1,139 +0,0 @@ -#!/usr/bin/env node -// Test: event-arrival race in digest job — fix verification -// -// The original bug: runJob fetched events via getEventsForSubscription(sub.id, since), -// sent the digest (slow), then deleted events via deleteEventsForSubscription(sub.id, since) -// which deletes EVERY row with received_at > since. Any event that arrived between -// the fetch and the delete was also received_at > since, so it was deleted without -// ever being emailed. -// -// The fix: deleteEventsByIds(sub.id, sentEventIds) deletes only the exact event IDs -// that were fetched and sent. A late-arriving event (inserted after the fetch) has -// a different ID and is not touched. -// -// This test simulates the sequence with the fixed approach: -// 1. Create a confirmed subscription with a known last_digest_at -// 2. Insert event A -// 3. Fetch events for the subscription (simulating runJob's fetch) -// 4. Insert event B after the fetch (simulating a /notify arriving during digest send) -// 5. Delete ONLY the event A IDs (the fix — instead of timestamp-based delete) -// 6. Verify event B survives (it arrived after fetch and was never sent) - -import * as db from '../dist/database.js' - -let passed = 0 -let failed = 0 - -function assert(label, ok, detail) { - if (ok) { - console.log(` ? ${label}`) - passed++ - } else { - console.log(` ? ${label} -- ${detail || ''}`) - failed++ - } -} - -function sleep(ms) { - return new Promise(resolve => setTimeout(resolve, ms)) -} - -async function main() { - await db.migrate() - - const pubkey = 'race-test-' + Date.now() - const email = 'race-test-' + Date.now() + '@example.com' - - // Step 1: Create and confirm subscription - console.log('1. Create confirmed subscription') - const sub = await db.insertSubscription(pubkey, email, 'daily') - assert('subscription created', !!sub, 'insert returned null') - if (!sub) { process.exit(1) } - - const confirmed = await db.confirmSubscription(sub.key) - assert('subscription confirmed', !!confirmed, 'confirm returned null') - if (!confirmed) { process.exit(1) } - - // Set last_digest_at to a known time well in the past. - // This is the "since" value runJob would use. - const since = Math.floor(Date.now() / 1000) - 60 // 60 seconds ago - await db.updateLastDigestAt(confirmed.id, since) - - // Wait 1s so event received_at timestamps (whole seconds) are strictly > since. - await sleep(1100) - - // Step 2: Insert Event A — simulates events that trigger a digest run - console.log('\n2. Insert Event A (triggers digest)') - const eventA_id = 'race-event-a-' + Date.now() - const eventA = { - id: eventA_id, - kind: 1, - pubkey: 'abc', - content: 'event A content', - created_at: Math.floor(Date.now() / 1000), - tags: [] - } - const storedA = await db.insertEvent(eventA_id, confirmed.id, eventA, 'wss://relay.damus.io') - assert('Event A stored', storedA === true, `got ${storedA}`) - - // Step 3: Fetch events — simulating what runJob does with since=last_digest_at - console.log('\n3. Fetch events for subscription (simulating runJob fetch)') - const fetched = await db.getEventsForSubscription(confirmed.id, since) - assert('Event A was fetched', fetched.some(e => e.id === eventA_id), - `fetched ids: [${fetched.map(e => e.id).join(', ')}]`) - - // Step 4: Insert Event B after the fetch — simulating a /notify arriving - // during the slow digest send (profile loads, MJML render, SMTP). - console.log('\n4. Insert Event B AFTER fetch (simulating event arriving during digest send)') - await sleep(100) // ensure distinct received_at - const eventB_id = 'race-event-b-' + Date.now() - const eventB = { - id: eventB_id, - kind: 1, - pubkey: 'def', - content: 'event B content — arrived during send', - created_at: Math.floor(Date.now() / 1000), - tags: [] - } - const storedB = await db.insertEvent(eventB_id, confirmed.id, eventB, 'wss://relay.damus.io') - assert('Event B stored', storedB === true, `got ${storedB}`) - - // Step 5: Delete ONLY the exact event IDs that were fetched + sent — THE FIX. - // Unlike the old timestamp-based delete, this does NOT touch Event B - // because Event B has a different ID. - console.log('\n5. Delete events by exact IDs (the fix — deleteEventsByIds)') - const sentIds = fetched.map(e => e.id) - await db.deleteEventsByIds(confirmed.id, sentIds) - console.log(' deleted ids:', JSON.stringify(sentIds)) - - // Step 6: Check which events remain. - // CORRECT BEHAVIOR: Only Event A (which was sent) is deleted. - // Event B (which arrived after fetch) must survive. - console.log('\n6. Check remaining events after delete') - const remaining = await db.getEventsForSubscription(confirmed.id, since - 10) - - const eventB_survived = remaining.some(e => e.id === eventB_id) - assert( - 'Event B survives the delete (it arrived after fetch and was never sent)', - eventB_survived, - `Event B was deleted despite never being sent. ` + - `remaining events: [${remaining.map(e => e.id).join(', ')}]` - ) - - // Verify Event A is gone (it was sent, so deletion is correct for A) - const eventA_survived = remaining.some(e => e.id === eventA_id) - assert( - 'Event A is deleted (it was fetched and sent)', - !eventA_survived, - `Event A should have been deleted but is still present` - ) - - console.log('') - console.log(`Results: ${passed} passed, ${failed} failed`) - process.exit(failed > 0 ? 1 : 0) -} - -main().catch(err => { - console.error('Unhandled error in test:', err) - process.exit(1) -}) \ No newline at end of file diff --git a/test/event-arrival-race.test.ts b/test/event-arrival-race.test.ts new file mode 100644 index 0000000..93e5c44 --- /dev/null +++ b/test/event-arrival-race.test.ts @@ -0,0 +1,89 @@ +import { describe, it, expect, beforeAll } from 'vitest' +import * as db from '../src/database.js' + +const pubkey = 'race-test-' + Date.now() +const email = 'race-test-' + Date.now() + '@example.com' +let sub: any = null +let since = 0 +const eventA_id = 'race-event-a-' + Date.now() +const eventB_id = 'race-event-b-' + Date.now() + +// Captured after the initial fetch, before Event B is inserted +let fetchedBeforeB: string[] = [] + +function sleep(ms: number) { + return new Promise(resolve => setTimeout(resolve, ms)) +} + +describe('Event-arrival race in digest job', () => { + beforeAll(async () => { + await db.migrate() + + // Create and confirm subscription + const s = await db.insertSubscription(pubkey, email, 'daily') + expect(s).toBeTruthy() + const confirmed = await db.confirmSubscription(s.key) + expect(confirmed).toBeTruthy() + sub = confirmed + + // Set last_digest_at to 60 seconds ago (the "since" value runJob would use) + since = Math.floor(Date.now() / 1000) - 60 + await db.updateLastDigestAt(sub.id, since) + + // Wait 1.1s so event received_at timestamps are strictly > since + await sleep(1100) + }) + + it('stores Event A (triggers digest)', async () => { + const eventA = { + id: eventA_id, + kind: 1, + pubkey: 'abc', + content: 'event A content', + created_at: Math.floor(Date.now() / 1000), + tags: [], + } + const storedA = await db.insertEvent(eventA_id, sub.id, eventA, 'wss://relay.damus.io') + expect(storedA).toBe(true) + }) + + // Fetch events BEFORE Event B is inserted (simulating runJob's fetch + // before a /notify arrives during the digest send). Save the IDs we + // fetched so we delete exactly those later. + it('fetches events and captures IDs (simulating runJob fetch)', async () => { + const fetched = await db.getEventsForSubscription(sub.id, since) + expect(fetched.some((e: any) => e.id === eventA_id)).toBe(true) + fetchedBeforeB = fetched.map((e: any) => e.id) + }) + + it('stores Event B AFTER fetch (simulating arrival during digest send)', async () => { + await sleep(100) + const eventB = { + id: eventB_id, + kind: 1, + pubkey: 'def', + content: 'event B content — arrived during send', + created_at: Math.floor(Date.now() / 1000), + tags: [], + } + const storedB = await db.insertEvent(eventB_id, sub.id, eventB, 'wss://relay.damus.io') + expect(storedB).toBe(true) + }) + + // Delete using the IDs captured before Event B was inserted. + // This simulates the fix: deleteEventsByIds, not timestamp-based delete. + it('deletes only previously-fetched event IDs (the fix) and leaves event B', async () => { + await db.deleteEventsByIds(sub.id, fetchedBeforeB) + + // Event B must survive (it arrived after fetch and was never sent) + const remaining = await db.getEventsForSubscription(sub.id, since - 10) + const eventB_survived = remaining.some((e: any) => e.id === eventB_id) + expect(eventB_survived).toBe(true) + }) + + it('Event A is deleted (it was fetched and sent)', async () => { + const remaining = await db.getEventsForSubscription(sub.id, since - 10) + const eventA_survived = remaining.some((e: any) => e.id === eventA_id) + expect(eventA_survived).toBe(false) + }) +}) \ No newline at end of file diff --git a/test/normalize-relay-url.test.js b/test/normalize-relay-url.test.js deleted file mode 100644 index 14a48f6..0000000 --- a/test/normalize-relay-url.test.js +++ /dev/null @@ -1,68 +0,0 @@ -#!/usr/bin/env node -// FAILING test: calling normalizeRelayUrl(undefined) crashes with -// TypeError: can't access property "match", A is undefined -// -// The bug: main.ts called normalizeRelayUrl(import.meta.env.VITE_NOTIFIER_RELAY) -// without guarding against the env var being undefined. When the env var is -// not set, normalizeRelayUrl crashes because it calls url.match(...) on -// undefined. -// -// The fix: replace the bare call with a ternary guard: -// const NOTIFIER_RELAY = import.meta.env.VITE_NOTIFIER_RELAY -// ? normalizeRelayUrl(import.meta.env.VITE_NOTIFIER_RELAY) -// : undefined -// -// This test validates that the guard pattern works correctly: when the env var -// is falsy (undefined, empty), the app gracefully sets NOTIFIER_RELAY to -// undefined without crashing. When it's a valid URL, normalization works. - -import { normalizeRelayUrl } from '/home/gascity/mailship/node_modules/.pnpm/@welshman+util@0.6.3_typescript@5.9.2/node_modules/@welshman/util/dist/util/src/Relay.js'; - -let passed = 0; -let failed = 0; - -function assert(label, ok, detail) { - if (ok) { - console.log(` ✓ ${label}`); - passed++; - } else { - console.log(` ✗ ${label} — ${detail || ''}`); - failed++; - } -} - -// Test 1: Guarded normalizeRelayUrl with undefined (the exact fix pattern) -console.log('1. Guarded normalizeRelayUrl with undefined (the fix)'); -const undefinedInput = undefined; -const guarded1 = undefinedInput ? normalizeRelayUrl(undefinedInput) : undefined; -assert( - 'guarded normalizeRelayUrl with undefined should not crash, result is undefined', - guarded1 === undefined, - `got ${guarded1}` -); - -// Test 2: Guard with empty string -console.log(''); -console.log('2. Guarded normalizeRelayUrl with empty string'); -const emptyInput = ''; -const guarded2 = emptyInput ? normalizeRelayUrl(emptyInput) : undefined; -assert( - 'guarded normalizeRelayUrl with "" should not crash, result is undefined', - guarded2 === undefined, - `got ${guarded2}` -); - -// Test 3: Guard with a valid relay still works -console.log(''); -console.log('3. Guarded normalizeRelayUrl with valid relay'); -const validInput = 'wss://relay.damus.io'; -const guarded3 = validInput ? normalizeRelayUrl(validInput) : undefined; -assert( - 'guarded normalizeRelayUrl with valid input still normalizes correctly', - guarded3 === 'wss://relay.damus.io/', - `got ${guarded3}` -); - -console.log(''); -console.log(`Results: ${passed} passed, ${failed} failed`); -process.exit(failed > 0 ? 1 : 0); diff --git a/test/normalize-relay-url.test.ts b/test/normalize-relay-url.test.ts new file mode 100644 index 0000000..7a5c2ab --- /dev/null +++ b/test/normalize-relay-url.test.ts @@ -0,0 +1,22 @@ +import { describe, it, expect } from 'vitest' +import { normalizeRelayUrl } from '@welshman/util' + +describe('Guarded normalizeRelayUrl', () => { + it('guarded with undefined should not crash, result is undefined', () => { + const undefinedInput: string | undefined = undefined + const guarded = undefinedInput ? normalizeRelayUrl(undefinedInput) : undefined + expect(guarded).toBeUndefined() + }) + + it('guarded with empty string should not crash, result is undefined', () => { + const emptyInput = '' + const guarded = emptyInput ? normalizeRelayUrl(emptyInput) : undefined + expect(guarded).toBeUndefined() + }) + + it('guarded with valid relay still normalizes correctly', () => { + const validInput = 'wss://relay.damus.io' + const guarded = validInput ? normalizeRelayUrl(validInput) : undefined + expect(guarded).toBe('wss://relay.damus.io/') + }) +}) \ No newline at end of file diff --git a/test/reschedule-on-frequency-change.test.js b/test/reschedule-on-frequency-change.test.js deleted file mode 100644 index f67229f..0000000 --- a/test/reschedule-on-frequency-change.test.js +++ /dev/null @@ -1,83 +0,0 @@ -#!/usr/bin/env node -// FAILING test: frequency change does not reschedule the running cron job. -// -// The fix: actions.ts:registerSubscription calls worker.registerSubscription() -// after a DB update on an already-confirmed subscription, so addJob creates -// a new CronJob with the updated frequency on the fly. -// -// Step 1-2: Create subscription via raw DB (bypass mailer for simplicity) -// Step 3: Register cron job with daily (simulating confirmSubscriptionAction) -// Step 4: Call registerSubscription with new frequency — the bug path -// BEFORE FIX: cron stays daily; AFTER FIX: cron becomes weekly - -import * as db from '../dist/database.js' -import { registerSubscription } from '../dist/actions.js' -import { getJobCronSource, removeJob } from '../dist/worker/email.js' -import { registerSubscription as regSub } from '../dist/worker/index.js' - -let passed = 0 -let failed = 0 - -function assert(label, ok, detail) { - if (ok) { - console.log(` ? ${label}`) - passed++ - } else { - console.log(` ? ${label} -- ${detail || ''}`) - failed++ - } -} - -async function main() { - await db.migrate() - - const pubkey = 'freq-test-' + Date.now() - const email = 'freq-test-' + Date.now() + '@example.com' - - // Step 1: Insert subscription directly (bypass mailer) and confirm - console.log('1. Create confirmed subscription with daily frequency') - const sub = await db.insertSubscription(pubkey, email, 'daily') - assert('subscription created', !!sub, 'insert returned null') - if (!sub) { process.exit(1) } - - const confirmed = await db.confirmSubscription(sub.key) - assert('subscription confirmed', !!confirmed, 'confirm returned null') - if (!confirmed) { process.exit(1) } - - // Step 2: Register cron job with daily (simulating confirmSubscriptionAction) - console.log('\n2. Register cron job with daily frequency') - regSub(confirmed) - const dailySource = getJobCronSource(confirmed.id) - assert( - 'cron source is daily', - dailySource === '0 0 17 * * *', - `expected 0 0 17 * * *, got ${dailySource}` - ) - - // Step 3: Register subscription again with weekly — the bug path. - // BEFORE FIX: registerSubscription skips worker call because - // sub.confirmed_at is set → cron stays daily - // AFTER FIX: registerSubscription calls worker.registerSubscription - // → addJob reschedules → cron becomes weekly - console.log('\n3. Change frequency to weekly via registerSubscription') - await registerSubscription({ pubkey, email, frequency: 'weekly' }) - const weeklySource = getJobCronSource(confirmed.id) - assert( - 'cron source is weekly after frequency change', - weeklySource === '0 0 17 * * 1', - `expected 0 0 17 * * 1, got ${weeklySource}` - ) - - // Cleanup - const updated = await db.getSubscriptionByPubkey(pubkey) - if (updated) removeJob(updated) - - console.log('') - console.log(`Results: ${passed} passed, ${failed} failed`) - process.exit(failed > 0 ? 1 : 0) -} - -main().catch(err => { - console.error('Unhandled error in test:', err) - process.exit(1) -}) \ No newline at end of file diff --git a/test/reschedule-on-frequency-change.test.ts b/test/reschedule-on-frequency-change.test.ts new file mode 100644 index 0000000..208ff93 --- /dev/null +++ b/test/reschedule-on-frequency-change.test.ts @@ -0,0 +1,42 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest' +import * as db from '../src/database.js' +import { registerSubscription } from '../src/actions.js' +import { getJobCronSource, removeJob } from '../src/worker/email.js' +import { registerSubscription as regSub } from '../src/worker/index.js' + +const pubkey = 'freq-test-' + Date.now() +const email = 'freq-test-' + Date.now() + '@example.com' +let sub: any = null + +describe('Frequency change reschedules cron job', () => { + beforeAll(async () => { + await db.migrate() + }) + + it('creates confirmed subscription with daily frequency', async () => { + const s = await db.insertSubscription(pubkey, email, 'daily') + expect(s).toBeTruthy() + sub = s + + const confirmed = await db.confirmSubscription(sub.key) + expect(confirmed).toBeTruthy() + sub = confirmed + }) + + it('registers cron job with daily frequency', () => { + regSub(sub) + const dailySource = getJobCronSource(sub.id) + expect(dailySource).toBe('0 0 17 * * *') + }) + + it('changes frequency to weekly via registerSubscription', async () => { + await registerSubscription({ pubkey, email, frequency: 'weekly' }) + const weeklySource = getJobCronSource(sub.id) + expect(weeklySource).toBe('0 0 17 * * 1') + }) +}) + +afterAll(async () => { + const updated = await db.getSubscriptionByPubkey(pubkey) + if (updated) removeJob(updated) +}) \ No newline at end of file diff --git a/test/setup.ts b/test/setup.ts new file mode 100644 index 0000000..d67ec3f --- /dev/null +++ b/test/setup.ts @@ -0,0 +1,23 @@ +// Vitest setup: set required env vars before test modules are loaded. +// env.ts checks these at module load time; they must be present when +// actions.ts / mailer.ts / etc. are imported. +import { mkdirSync } from 'fs' + +const dataDir = 'test-data-unit' +mkdirSync(dataDir, { recursive: true }) + +process.env.MAILSHIP_URL = 'http://localhost:3000' +process.env.MAILSHIP_NAME = 'Test Mailship' +process.env.MAILSHIP_SECRET = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef' +process.env.SMTP_HOST = 'localhost' +process.env.SMTP_PORT = '587' +process.env.SMTP_USER = 'test@test.com' +process.env.SMTP_PASSWORD = 'test' +process.env.SMTP_FROM = 'test@test.com' +process.env.DEFAULT_RELAYS = 'wss://relay.damus.io' +process.env.INDEXER_RELAYS = 'wss://purplepag.es' +process.env.SEARCH_RELAYS = 'wss://relay.nostr.band' +process.env.PORT = '3000' +process.env.CORS_ORIGIN = 'http://localhost:5173' +process.env.BASE_URL = 'http://localhost:3000' +process.env.DATA_DIR = dataDir \ No newline at end of file diff --git a/test/web-ui.test.js b/test/web-ui.test.js deleted file mode 100644 index 207bcb2..0000000 --- a/test/web-ui.test.js +++ /dev/null @@ -1,61 +0,0 @@ -#!/usr/bin/env node -// Failing test: web UI crashes on load when VITE_NOTIFIER_RELAY is not set. -// -// The bug: `normalizeRelayUrl(import.meta.env.VITE_NOTIFIER_RELAY)` throws -// TypeError: Cannot read properties of undefined (reading 'match') -// when the env var is not set. -// -// After the fix, `normalizeRelayUrl` is only called when the env var is -// truthy, so the crash no longer occurs. This test verifies the guarded -// call pattern matches the one in main.ts. - -import { normalizeRelayUrl } from '/home/gascity/mailship/node_modules/.pnpm/@welshman+util@0.6.3_typescript@5.9.2/node_modules/@welshman/util/dist/util/src/Relay.js'; - -let passed = 0; -let failed = 0; - -function assert(label, ok, detail) { - if (ok) { - console.log(` ✓ ${label}`); - passed++; - } else { - console.log(` ✗ ${label} — ${detail || ''}`); - failed++; - } -} - -// Test 1: Guarded normalizeRelayUrl — the pattern used in main.ts -console.log('1. Guarded normalizeRelayUrl (main.ts pattern)'); -const undefinedInput = undefined; // simulates unset VITE_NOTIFIER_RELAY -const guarded1 = undefinedInput ? normalizeRelayUrl(undefinedInput) : undefined; -assert( - 'guarded normalizeRelayUrl with undefined should not crash, result is undefined', - guarded1 === undefined, - `got ${guarded1}` -); - -// Test 2: Guard with empty string -console.log(''); -console.log('2. Guarded normalizeRelayUrl with empty string'); -const emptyInput = ''; -const guarded2 = emptyInput ? normalizeRelayUrl(emptyInput) : undefined; -assert( - 'guarded normalizeRelayUrl with "" should not crash, result is undefined', - guarded2 === undefined, - `got ${guarded2}` -); - -// Test 3: Guard with a valid relay still works -console.log(''); -console.log('3. Guarded normalizeRelayUrl with valid relay'); -const validInput = 'wss://relay.damus.io'; -const guarded3 = validInput ? normalizeRelayUrl(validInput) : undefined; -assert( - 'guarded normalizeRelayUrl with valid input still normalizes correctly', - guarded3 === 'wss://relay.damus.io/', - `got ${guarded3}` -); - -console.log(''); -console.log(`Results: ${passed} passed, ${failed} failed`); -process.exit(failed > 0 ? 1 : 0); \ No newline at end of file diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..255780e --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + globals: true, + include: ['test/**/*.test.ts'], + setupFiles: ['test/setup.ts'], + }, +}) \ No newline at end of file