From 13b1e9d00f262c13f1f2c2115129c22af836fb26 Mon Sep 17 00:00:00 2001 From: Agent Date: Mon, 14 Sep 2026 13:29:54 -0400 Subject: [PATCH] Standardize tests on vitest - Add vitest as dev dep (pnpm add -D vitest), create vitest.config.ts with globals:true and setupFiles - Migrate digest-template, normalize-relay-url, reschedule-on-frequency-change, and event-arrival-race from raw JS + hand-rolled asserts to vitest describe/it/expect, as .test.ts (port all assertions without weakening) - Create test/setup.ts with env vars needed by env.ts/mailer.ts - DELETE test/web-ui.test.js: dead test for removed web UI (PR #1) - package.json: add test:unit script (vitest run), keep test = bash E2E - script/checks: add pnpm test:unit after build - README: document pnpm test:unit / pnpm test --- README.md | 5 +- package.json | 4 +- pnpm-lock.yaml | Bin 150827 -> 172089 bytes script/checks | 5 +- test/digest-template.test.js | 74 ----------- test/digest-template.test.ts | 33 +++++ test/event-arrival-race.test.js | 139 -------------------- test/event-arrival-race.test.ts | 89 +++++++++++++ test/normalize-relay-url.test.js | 68 ---------- test/normalize-relay-url.test.ts | 22 ++++ test/reschedule-on-frequency-change.test.js | 83 ------------ test/reschedule-on-frequency-change.test.ts | 42 ++++++ test/setup.ts | 23 ++++ test/web-ui.test.js | 61 --------- vitest.config.ts | 9 ++ 15 files changed, 227 insertions(+), 430 deletions(-) delete mode 100644 test/digest-template.test.js create mode 100644 test/digest-template.test.ts delete mode 100644 test/event-arrival-race.test.js create mode 100644 test/event-arrival-race.test.ts delete mode 100644 test/normalize-relay-url.test.js create mode 100644 test/normalize-relay-url.test.ts delete mode 100644 test/reschedule-on-frequency-change.test.js create mode 100644 test/reschedule-on-frequency-change.test.ts create mode 100644 test/setup.ts delete mode 100644 test/web-ui.test.js create mode 100644 vitest.config.ts 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 15e0f817fa1b101a5c14be4aed0f233305dfd049..f444b0528ec9a5b99489fa6cd9b34333329db50d 100644 GIT binary patch delta 15312 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 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 -- 2.45.2