Merge pull request 'Port cron-minutely fixes: reactivate tombstoned subs + confirm-email rate limit' (#29) from mailship-fc-merge-cron-fixes-df6 into main
Reviewed-on: #29 Reviewed-by: matt <matt@lorentz.is>
This commit is contained in:
commit
9ad5c4eef4
7 changed files with 519 additions and 4 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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<Row>(
|
||||
`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) => {
|
||||
|
|
|
|||
109
test/confirm-code-on-email-change.test.ts
Normal file
109
test/confirm-code-on-email-change.test.ts
Normal file
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
101
test/confirm-email-cooldown.test.ts
Normal file
101
test/confirm-email-cooldown.test.ts
Normal file
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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 "========================================="
|
||||
|
|
|
|||
188
test/resubscribe-reactivates.test.ts
Normal file
188
test/resubscribe-reactivates.test.ts
Normal file
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue