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
This commit is contained in:
parent
cfbb29cb96
commit
13b1e9d00f
15 changed files with 227 additions and 430 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
BIN
pnpm-lock.yaml
BIN
pnpm-lock.yaml
Binary file not shown.
|
|
@ -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
|
||||
pnpm run build
|
||||
pnpm test:unit
|
||||
|
|
@ -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);
|
||||
33
test/digest-template.test.ts
Normal file
33
test/digest-template.test.ts
Normal file
|
|
@ -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}}')
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
89
test/event-arrival-race.test.ts
Normal file
89
test/event-arrival-race.test.ts
Normal file
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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);
|
||||
22
test/normalize-relay-url.test.ts
Normal file
22
test/normalize-relay-url.test.ts
Normal file
|
|
@ -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/')
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
})
|
||||
42
test/reschedule-on-frequency-change.test.ts
Normal file
42
test/reschedule-on-frequency-change.test.ts
Normal file
|
|
@ -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)
|
||||
})
|
||||
23
test/setup.ts
Normal file
23
test/setup.ts
Normal file
|
|
@ -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
|
||||
|
|
@ -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);
|
||||
9
vitest.config.ts
Normal file
9
vitest.config.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
include: ['test/**/*.test.ts'],
|
||||
setupFiles: ['test/setup.ts'],
|
||||
},
|
||||
})
|
||||
Loading…
Reference in a new issue