diff --git a/src/actions.ts b/src/actions.ts index dec2608..a604033 100644 --- a/src/actions.ts +++ b/src/actions.ts @@ -19,6 +19,11 @@ export type RegisterSubscriptionParams = { timezone?: string } +// Floor on how often a confirmation email can be sent for the same subscription. +// The client is expected to only PUT on an explicit save (GET-first on boot), +// but a retry loop or refresh storm shouldn't be able to spam an inbox either. +export const CONFIRM_EMAIL_COOLDOWN_SECONDS = 10 * 60 + export const registerSubscription = instrument( 'actions.registerSubscription', async ({ pubkey, email, frequency, hour, minute, dayOfWeek, timezone }: RegisterSubscriptionParams) => { @@ -26,8 +31,18 @@ export const registerSubscription = instrument( const callback = `${process.env.BASE_URL}/notify/${sub.id}` if (!sub.confirmed_at) { - // New or email-changed subscription — send a confirmation email. - await mailer.sendConfirm(sub) + // New or email-changed subscription — send a confirmation email, but never + // more than once per cooldown window. The email-change path resets + // last_confirm_sent_at, so a genuinely new address always gets an email + // right away; this only throttles repeated sends of the same address. + const lastSent = sub.last_confirm_sent_at + const cooldownElapsed = + !lastSent || Math.floor(Date.now() / 1000) - lastSent >= CONFIRM_EMAIL_COOLDOWN_SECONDS + + if (cooldownElapsed) { + await mailer.sendConfirm(sub) + await db.markConfirmSent(sub.id) + } } else { // Already confirmed (e.g. frequency-only change) — reschedule the // cron job so it uses the new cadence immediately. diff --git a/src/alert.ts b/src/alert.ts index 542086b..2f3dadf 100644 --- a/src/alert.ts +++ b/src/alert.ts @@ -12,6 +12,7 @@ export type Subscription = { confirmed_at?: number unsubscribed_at?: number last_digest_at?: number + last_confirm_sent_at?: number } export const getSubscriptionError = (sub: Subscription) => { diff --git a/src/database.ts b/src/database.ts index ce52cc3..e7053c7 100644 --- a/src/database.ts +++ b/src/database.ts @@ -65,7 +65,8 @@ export const migrate = () => created_at INTEGER NOT NULL, confirmed_at INTEGER, unsubscribed_at INTEGER, - last_digest_at INTEGER + last_digest_at INTEGER, + last_confirm_sent_at INTEGER ) ` ) @@ -81,6 +82,14 @@ export const migrate = () => ) ` ) + // Add last_confirm_sent_at for existing databases created before the + // confirmation-email cooldown migration. + const columns = await all( + `SELECT name FROM pragma_table_info('subscriptions')` + ) + if (!columns.some((c: any) => c.name === 'last_confirm_sent_at')) { + await run(`ALTER TABLE subscriptions ADD COLUMN last_confirm_sent_at INTEGER`) + } await run( `CREATE INDEX IF NOT EXISTS idx_events_subscription_received ON events (subscription_id, received_at)` ) @@ -171,9 +180,11 @@ export const updateSubscription = instrument( ) } + // Address changed: it's a new inbox to verify, so reset the confirm-email + // cooldown or the new address would inherit the old one's lockout. return parseSubscription( await get( - `UPDATE subscriptions SET email = ?, frequency = ?, hour = ?, minute = ?, day_of_week = ?, timezone = ?, confirmed_at = NULL, unsubscribed_at = NULL + `UPDATE subscriptions SET email = ?, frequency = ?, hour = ?, minute = ?, day_of_week = ?, timezone = ?, confirmed_at = NULL, unsubscribed_at = NULL, last_confirm_sent_at = NULL WHERE id = ? RETURNING *`, [email, frequency, hr, mn, dow, tz, existing.id] ) @@ -181,6 +192,35 @@ export const updateSubscription = instrument( } ) +// Record that a confirmation email was sent, for the send-cooldown. +export const markConfirmSent = instrument( + 'database.markConfirmSent', + async (id: string) => { + await run(`UPDATE subscriptions SET last_confirm_sent_at = ? WHERE id = ?`, [now(), id]) + } +) + +const getMostRecentTombstonedSubscription = async (pubkey: string, email: string) => + parseSubscription( + await get( + `SELECT * FROM subscriptions + WHERE pubkey = ? AND email = ? AND unsubscribed_at IS NOT NULL + ORDER BY rowid DESC LIMIT 1`, + [pubkey, email] + ) + ) + +const reactivateSubscription = async (tombstoned: Subscription, frequency: string) => + parseSubscription( + await get( + // A fresh key invalidates any previously emailed confirm link, so a new + // confirmation email must be allowed immediately (reset the cooldown). + `UPDATE subscriptions SET key = ?, frequency = ?, unsubscribed_at = NULL, last_confirm_sent_at = NULL + WHERE id = ? RETURNING *`, + [crypto.randomBytes(32).toString('hex'), frequency, tombstoned.id] + ) + ) + export const insertSubscription = instrument( 'database.insertSubscription', async (pubkey: string, email: string, frequency: string, hour?: number, minute?: number, dayOfWeek?: number, timezone?: string) => { @@ -190,6 +230,28 @@ export const insertSubscription = instrument( return assertResult(await updateSubscription(existing, email, frequency, hour, minute, dayOfWeek, timezone)) } + // No active row. If this account previously subscribed to this email and + // was unsubscribed, reactivate the most recent such row instead of inserting + // a fresh one. Otherwise every turn-on → turn-off → turn-on cycle would + // create a new row, abandoning the confirmed state (and the already-known key). + const tombstoned = await getMostRecentTombstonedSubscription(pubkey, email) + + if (tombstoned) { + try { + return assertResult(await reactivateSubscription(tombstoned, frequency)) + } catch (err: any) { + if (err.message?.includes('UNIQUE constraint')) { + const concurrent = await getSubscriptionByPubkey(pubkey) + + if (concurrent) { + return assertResult(await updateSubscription(concurrent, email, frequency)) + } + } + + throw err + } + } + try { return assertResult( parseSubscription( @@ -309,6 +371,20 @@ export const getActiveSubscriptions = instrument('database.getActiveSubscription return rows.map(parseSubscription) as Subscription[] }) +// Every row ever created for a pubkey — both active and tombstoned. Exposed +// primarily for tests asserting re-subscribe never grows the table. +export const getAllSubscriptionsByPubkey = instrument( + 'database.getAllSubscriptionsByPubkey', + async (pubkey: string) => { + const rows = await all( + `SELECT * FROM subscriptions WHERE pubkey = ? ORDER BY created_at`, + [pubkey] + ) + + return rows.map(parseSubscription) as Subscription[] + } +) + export const updateLastDigestAt = instrument( 'database.updateLastDigestAt', async (id: string, timestamp: number) => { diff --git a/test/confirm-code-on-email-change.test.ts b/test/confirm-code-on-email-change.test.ts new file mode 100644 index 0000000..d0d0095 --- /dev/null +++ b/test/confirm-code-on-email-change.test.ts @@ -0,0 +1,109 @@ +import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest' +import * as db from '../src/database.js' +import { registerSubscription } from '../src/actions.js' +import * as mailer from '../src/mailer.js' + +// Regression guards for the row-reactivation fix (src/database.ts): +// * same-email re-subscribe → reactivates the SAME row and must NOT email a +// stale confirmation code to an unrelated address +// * new-email re-subscribe → MUST create a fresh row (fresh key) so the +// confirmation email carries a code bound to the new address +// +// We mock the mailer so these run hermetically (the unit env's SMTP is a dummy). + +vi.mock('../src/mailer.js', async (importOriginal) => { + const actual: any = await importOriginal() + return { ...actual, sendConfirm: vi.fn(async () => {}) } +}) + +const pubkey = 'new-email-' + Date.now() + '-' + Math.random().toString(36).slice(2) +const oldEmail = `${pubkey}-old@example.com` +const newEmail = `${pubkey}-new@example.com` +let subscribedIds: string[] = [] + +const subscribeIdsFor = async () => { + const rows = await db.getAllSubscriptionsByPubkey(pubkey) + subscribedIds = rows.map((r: any) => r.id) + return rows +} + +describe('Re-subscribe with a NEW email dispatches the correct confirmation code', () => { + beforeAll(async () => { + await db.migrate() + vi.mocked(mailer.sendConfirm).mockClear() + }) + + afterAll(async () => { + const key = (await db.getAllSubscriptionsByPubkey(pubkey))[0]?.key + if (key) await db.unsubscribeSubscription(key) + }) + + it('registers + confirms the original email', async () => { + const result = await registerSubscription({ pubkey, email: oldEmail, frequency: 'daily' }) + expect(result.key).toBeTruthy() + await db.confirmSubscription(result.key) + expect(vi.mocked(mailer.sendConfirm)).toHaveBeenCalledTimes(1) + }) + + it('unsubscribes (DELETE flow)', async () => { + const active = await db.getSubscriptionByPubkey(pubkey) + await db.unsubscribeSubscription(active!.key) + expect(await db.getSubscriptionByPubkey(pubkey)).toBeFalsy() + }) + + it('re-registering with the NEW email creates a fresh row, not a reactivation', async () => { + vi.mocked(mailer.sendConfirm).mockClear() + + const result = await registerSubscription({ pubkey, email: newEmail, frequency: 'daily' }) + + // A NEW confirmation email was sent — to the NEW address, with the key + // returned to the caller (which the caller uses to confirm). + const sent = vi.mocked(mailer.sendConfirm).mock.calls[0][0] + expect(sent.email).toBe(newEmail) + expect(sent.key).toBe(result.key) + + // And that key actually confirms the new-email row (not the old one). + const confirmed = await db.confirmSubscription(result.key) + expect(confirmed!.sub.email).toBe(newEmail) + expect(confirmed!.alreadyConfirmed).toBe(false) + }) + + it('leaves the old row and new row as distinct rows', async () => { + const rows = await subscribeIdsFor() + expect(rows).toHaveLength(2) + expect(new Set(subscribedIds).size).toBe(2) + }) +}) + +describe('Reactivating the SAME email never emails an unrelated address', () => { + const k = 'same-email-' + Date.now() + '-' + Math.random().toString(36).slice(2) + const email = `${k}@example.com` + + beforeAll(async () => { + vi.mocked(mailer.sendConfirm).mockClear() + }) + + afterAll(async () => { + const key = (await db.getAllSubscriptionsByPubkey(k))[0]?.key + if (key) await db.unsubscribeSubscription(key) + }) + + it('register + confirm + unsubscribe, then re-register the same email', async () => { + const r1 = await registerSubscription({ pubkey: k, email, frequency: 'daily' }) + await db.confirmSubscription(r1.key) + const row1 = (await db.getAllSubscriptionsByPubkey(k))[0] + await db.unsubscribeSubscription(r1.key) + + vi.mocked(mailer.sendConfirm).mockClear() + const r2 = await registerSubscription({ pubkey: k, email, frequency: 'daily' }) + + // Same row, still confirmed, but a FRESH key is issued — no new + // confirmation email, and old tokens for this row are invalidated. + const rows = await db.getAllSubscriptionsByPubkey(k) + expect(rows).toHaveLength(1) + expect(r2.key).not.toBe(r1.key) + expect(rows[0].id).toBe(row1.id) + expect(rows[0].confirmed_at).toBeTruthy() + expect(vi.mocked(mailer.sendConfirm)).not.toHaveBeenCalled() + }) +}) \ No newline at end of file diff --git a/test/confirm-email-cooldown.test.ts b/test/confirm-email-cooldown.test.ts new file mode 100644 index 0000000..731418d --- /dev/null +++ b/test/confirm-email-cooldown.test.ts @@ -0,0 +1,101 @@ +import { describe, it, expect, beforeAll, vi, afterEach, beforeEach } from 'vitest' +import * as db from '../src/database.js' +import { registerSubscription, CONFIRM_EMAIL_COOLDOWN_SECONDS } from '../src/actions.js' +import * as mailer from '../src/mailer.js' + +// Guard: at most one confirmation email per subscription per cooldown window, +// no matter how many PUTs arrive (e.g. a page-refresh storm while unconfirmed). + +vi.mock('../src/mailer.js', async (importOriginal) => { + const actual: any = await importOriginal() + return { ...actual, sendConfirm: vi.fn(async () => {}) } +}) + +const unique = (label: string) => `${label}-${Date.now()}-${Math.random().toString(36).slice(2)}` +const pubkey = unique('cooldown') +const email = `${unique('cooldown')}@example.com` + +const confirmCalls = () => vi.mocked(mailer.sendConfirm).mock.calls.length + +describe('Confirmation email cooldown', () => { + beforeAll(async () => { + await db.migrate() + }) + + beforeEach(() => { + vi.mocked(mailer.sendConfirm).mockClear() + }) + + it('sends a confirmation email on the first register', async () => { + const res = await registerSubscription({ pubkey, email, frequency: 'daily' }) + expect(res.key).toBeTruthy() + expect(confirmCalls()).toBe(1) + }) + + it('does NOT re-send a confirmation email on re-register (refresh/retry storm)', async () => { + // Simulate several PUTs arriving in quick succession (e.g. page reloads). + for (let i = 0; i < 5; i++) { + await registerSubscription({ pubkey, email, frequency: 'daily' }) + } + expect(confirmCalls()).toBe(0) + }) + + it('a new frequency (setting change) does not re-send', async () => { + await registerSubscription({ pubkey, email, frequency: 'weekly' }) + expect(confirmCalls()).toBe(0) + }) + + it('changing the email address sends immediately (cooldown reset)', async () => { + const newEmail = `${unique('cooldown')}@example.com` + await registerSubscription({ pubkey, email: newEmail, frequency: 'daily' }) + expect(confirmCalls()).toBe(1) + }) + + it('a fresh unconfirmed subscription is still throttled after confirm-sent marker', async () => { + // New pubkey: first PUT sends one email; immediate re-PUT is suppressed. + const pk2 = unique('cooldown2') + const em2 = `${unique('cooldown2')}@example.com` + await registerSubscription({ pubkey: pk2, email: em2, frequency: 'daily' }) + await registerSubscription({ pubkey: pk2, email: em2, frequency: 'daily' }) + expect(confirmCalls()).toBe(1) + }) + + it('cooldown window constant is 10 minutes', () => { + expect(CONFIRM_EMAIL_COOLDOWN_SECONDS).toBe(600) + }) +}) + +describe('Cooldown reset on subscription reactivation (same email, off/on cycle)', () => { + const k = unique('cooldown-reactivate') + const e = `${unique('cooldown-reactivate')}@example.com` + + beforeAll(async () => { + await db.migrate() + }) + + beforeEach(() => { + vi.mocked(mailer.sendConfirm).mockClear() + }) + + it('register, confirm, unsubscribe, re-register same email → fresh key emails immediately', async () => { + const r1 = await registerSubscription({ pubkey: k, email: e, frequency: 'daily' }) + const active = await db.getSubscriptionByPubkey(k) + + // Confirm first so reactivation is a "keep confirmed" path — but that also + // means no confirm email on re-register (already confirmed). Verify the row + // is reactivated with a fresh key, not a duplicate. + const confirmed = await db.confirmSubscription(active!.key) + expect(confirmed).toBeTruthy() + + await db.unsubscribeSubscription(active!.key) + + vi.mocked(mailer.sendConfirm).mockClear() + const r2 = await registerSubscription({ pubkey: k, email: e, frequency: 'daily' }) + + expect(r2.key).not.toBe(r1.key) // fresh key issued + expect(confirmCalls()).toBe(0) // still confirmed → no new email + + const rows = await db.getAllSubscriptionsByPubkey(k) + expect(rows).toHaveLength(1) + }) +}) \ No newline at end of file diff --git a/test/integration.sh b/test/integration.sh index c21a0d4..c0973fb 100644 --- a/test/integration.sh +++ b/test/integration.sh @@ -266,6 +266,31 @@ echo "14. Delete without auth returns 401" DEL_NO_AUTH=$(curl -s "$BASE_URL/subscription/$KEY" -X DELETE) check_field "No-auth DELETE returns 401" "$DEL_NO_AUTH" "error" "NIP-98 authorization required" +# Test 15: Re-subscribe after delete reactivates the same row (no duplicate) +# Regression: DELETE tombstones the row; re-subscribing with the SAME pubkey + +# email must reactivate it, not insert a second row (off/on cycle previously +# accumulated one row per cycle). +echo "" +echo "15. Re-subscribe after delete (de-dupe regression)" +OLD_ID=$(sqlite3 "$DB_PATH" "SELECT id FROM subscriptions WHERE pubkey='$CLIENT_PUBKEY' AND unsubscribed_at IS NOT NULL ORDER BY created_at DESC LIMIT 1;" 2>/dev/null) +AUTH_RE=$(nip98_auth "$BASE_URL/subscription/email" PUT '{"email":"test@example.com","frequency":"daily"}') +RE_REG=$(curl -s "$BASE_URL/subscription/email" -X PUT -H "Content-Type: application/json" \ + -H "Authorization: $AUTH_RE" \ + -d '{"email":"test@example.com","frequency":"daily"}') +NEW_ID=$(sqlite3 "$DB_PATH" "SELECT id FROM subscriptions WHERE pubkey='$CLIENT_PUBKEY' AND unsubscribed_at IS NULL LIMIT 1;" 2>/dev/null) +if [ -n "$OLD_ID" ] && [ "$NEW_ID" = "$OLD_ID" ]; then + pass "Re-subscribe reactivates the same row" +else + fail "Re-subscribe created a duplicate row (old=$OLD_ID, new=$NEW_ID)" +fi + +TOTAL_ROWS=$(sqlite3 "$DB_PATH" "SELECT COUNT(*) FROM subscriptions WHERE pubkey='$CLIENT_PUBKEY';" 2>/dev/null) +if [ "$TOTAL_ROWS" = "1" ]; then + pass "Exactly one row for this pubkey" +else + fail "Expected 1 row for this pubkey, got $TOTAL_ROWS" +fi + # Summary echo "" echo "=========================================" diff --git a/test/resubscribe-reactivates.test.ts b/test/resubscribe-reactivates.test.ts new file mode 100644 index 0000000..f779bf3 --- /dev/null +++ b/test/resubscribe-reactivates.test.ts @@ -0,0 +1,188 @@ +import { describe, it, expect, beforeAll } from 'vitest' +import * as db from '../src/database.js' + +// Regression test for the "two rows created when turning email alerts back on" bug. +// +// Decoded production sequence (Sep 18 2026): +// 13:42:05 register → row 1 (unconfirmed), confirm email #1 +// 13:44:35 DELETE → row 1 tombstoned (unsubscribed_at set) +// 13:44:36 register → OLD BUG: a brand-new row 2 was inserted, confirm email #2 +// 14:02:32 confirm → row 2 finally confirmed +// +// Fix: when the same pubkey re-subscribes to the same email after being +// unsubscribed, reactivate the tombstoned row instead of inserting a new one, +// preserving the existing confirmed state and key. + +const unique = (label: string) => `${label}-${Date.now()}-${Math.random().toString(36).slice(2)}` + +describe('Re-subscribe after DELETE reactivates the same subscription (off/on cycle)', () => { + const pubkey = unique('resub') + const email = `${unique('resub')}@example.com` + + beforeAll(async () => { + await db.migrate() + }) + + it('creates an unconfirmed subscription', async () => { + const sub = await db.insertSubscription(pubkey, email, 'daily') + expect(sub).toBeTruthy() + expect(sub.confirmed_at).toBeFalsy() + expect(sub.unsubscribed_at).toBeFalsy() + }) + + it('confirms it (user clicks the confirm link)', async () => { + const active = await db.getSubscriptionByPubkey(pubkey) + const result = await db.confirmSubscription(active!.key) + expect(result).toBeTruthy() + expect(result!.alreadyConfirmed).toBe(false) + expect(result!.sub.confirmed_at).toBeTruthy() + }) + + it('unsubscribes it (the flotilla DELETE path)', async () => { + const active = await db.getSubscriptionByPubkey(pubkey) + const gone = await db.unsubscribeSubscription(active!.key) + expect(gone).toBeTruthy() + expect(gone!.unsubscribed_at).toBeTruthy() + expect(await db.getSubscriptionByPubkey(pubkey)).toBeFalsy() + }) + + it('re-registers the SAME email — must reactivate row, NOT create a second row', async () => { + const resigned = await db.insertSubscription(pubkey, email, 'daily') + + // Restores the very same row + const active = await db.getSubscriptionByPubkey(pubkey) + expect(active).toBeTruthy() + expect(active!.key).toBe(resigned.key) + expect(active!.unsubscribed_at).toBeFalsy() + + // Confirmed state is preserved — no second confirmation email needed + expect(active!.confirmed_at).toBeTruthy() + + // Exhaustive: there exists exactly one row total for this pubkey + const rows = await db.getAllSubscriptionsByPubkey(pubkey) + expect(rows).toHaveLength(1) + expect(rows[0].id).toBe(active!.id) + }) +}) + +describe('Re-subscribe after DELETE before ever confirming', () => { + const pubkey = unique('resub-unconfirmed') + const email = `${unique('resub-unconfirmed')}@example.com` + + beforeAll(async () => { + await db.migrate() + }) + + it('registers then unsubscribes without confirming', async () => { + const sub = await db.insertSubscription(pubkey, email, 'daily') + await db.unsubscribeSubscription(sub.key) + expect(await db.getSubscriptionByPubkey(pubkey)).toBeFalsy() + }) + + it('re-registers the same email — reactivates same row, still unconfirmed', async () => { + const resigned = await db.insertSubscription(pubkey, email, 'daily') + const active = await db.getSubscriptionByPubkey(pubkey) + + expect(active!.key).toBe(resigned.key) + expect(active!.id).toBe(resigned.id) + expect(active!.unsubscribed_at).toBeFalsy() + // Was never confirmed, and re-subscribing does not skip confirmation + expect(active!.confirmed_at).toBeFalsy() + + const rows = await db.getAllSubscriptionsByPubkey(pubkey) + expect(rows).toHaveLength(1) + }) +}) + +describe('Re-subscribe with a DIFFERENT email after DELETE', () => { + const pubkey = unique('resub-2') + const email = `${unique('resub-2')}@example.com` + + beforeAll(async () => { + await db.migrate() + }) + + it('registers, confirms, and unsubscribes', async () => { + const sub = await db.insertSubscription(pubkey, email, 'daily') + await db.confirmSubscription(sub.key) + await db.unsubscribeSubscription(sub.key) + expect(await db.getSubscriptionByPubkey(pubkey)).toBeFalsy() + }) + + it('a new email creates a new row, leaving the old one tombstoned', async () => { + const newEmail = `${unique('resub-2-new')}@example.com` + const resigned = await db.insertSubscription(pubkey, newEmail, 'daily') + + expect(resigned.email).toBe(newEmail) + expect(resigned.confirmed_at).toBeFalsy() // new address must re-confirm + + const rows = await db.getAllSubscriptionsByPubkey(pubkey) + expect(rows).toHaveLength(2) + expect(rows[0].email).toBe(email) // old, tombstoned + expect(rows[0].unsubscribed_at).toBeTruthy() + expect(rows[1].email).toBe(newEmail) // new, active + expect(rows[1].unsubscribed_at).toBeFalsy() + }) +}) + +describe('Re-subscribe with a changed frequency reactivates and updates cadence', () => { + const pubkey = unique('resub-freq') + const email = `${unique('resub-freq')}@example.com` + + beforeAll(async () => { + await db.migrate() + }) + + it('registers confirm-free, unsubscribes', async () => { + const sub = await db.insertSubscription(pubkey, email, 'daily') + await db.unsubscribeSubscription(sub.key) + }) + + it('re-registers with weekly — reactivates the same row at the new cadence', async () => { + const resigned = await db.insertSubscription(pubkey, email, 'weekly') + const active = await db.getSubscriptionByPubkey(pubkey) + + expect(active!.key).toBe(resigned.key) + expect(active!.frequency).toBe('weekly') + expect(active!.unsubscribed_at).toBeFalsy() + + const rows = await db.getAllSubscriptionsByPubkey(pubkey) + expect(rows).toHaveLength(1) + }) +}) + +describe('Re-subscribe with mixed emails for one pubkey picks the right row', () => { + const pubkey = unique('resub-mixed') + const emailA = `${unique('resub-mixed-a')}@example.com` + const emailB = `${unique('resub-mixed-b')}@example.com` + + beforeAll(async () => { + await db.migrate() + }) + + it('creates and tombstones two different emails, then re-subscribes email B', async () => { + // Email A cycle + const subA = await db.insertSubscription(pubkey, emailA, 'daily') + await db.unsubscribeSubscription(subA.key) + + // Email B cycle + const subB = await db.insertSubscription(pubkey, emailB, 'daily') + const bId = subB!.id + await db.unsubscribeSubscription(subB.key) + + // Re-subscribe with email B — must reactivate B's own row, not A's, + // and must not resurrect the wrong email. + const resigned = await db.insertSubscription(pubkey, emailB, 'daily') + const active = await db.getSubscriptionByPubkey(pubkey) + + expect(active!.id).toBe(bId) + expect(active!.id).not.toBe(subA!.id) + expect(active!.email).toBe(emailB) + expect(active!.unsubscribed_at).toBeFalsy() + + const rows = await db.getAllSubscriptionsByPubkey(pubkey) + expect(rows).toHaveLength(2) + expect(rows.filter(r => r.email === emailA)).toHaveLength(1) + expect(rows.filter(r => r.email === emailB)).toHaveLength(1) + }) +}) \ No newline at end of file