Files
simstudioai--sim/apps/sim/lib/messaging/email/unsubscribe.test.ts
T
wehub-resource-sync d25d482dc2
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

531 lines
16 KiB
TypeScript

import { createEnvMock, databaseMock } from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { EmailType } from '@/lib/messaging/email/mailer'
vi.mock('drizzle-orm', () => ({
eq: vi.fn((a, b) => ({ type: 'eq', left: a, right: b })),
}))
const mockDb = databaseMock.db as Record<string, ReturnType<typeof vi.fn>>
vi.mock('@/lib/core/config/env', () => createEnvMock({ BETTER_AUTH_SECRET: 'test-secret-key' }))
import {
generateUnsubscribeToken,
getEmailPreferences,
isTransactionalEmail,
isUnsubscribed,
resubscribe,
unsubscribeFromAll,
updateEmailPreferences,
verifyUnsubscribeToken,
} from '@/lib/messaging/email/unsubscribe'
describe('unsubscribe utilities', () => {
const testEmail = 'test@example.com'
const testEmailType = 'marketing'
beforeEach(() => {
vi.clearAllMocks()
})
describe('generateUnsubscribeToken', () => {
it.concurrent('should generate a token with salt:hash:emailType format', () => {
const token = generateUnsubscribeToken(testEmail, testEmailType)
const parts = token.split(':')
expect(parts).toHaveLength(3)
expect(parts[0]).toHaveLength(32) // Salt should be 32 chars (16 bytes hex)
expect(parts[1]).toHaveLength(64) // SHA256 hash should be 64 chars
expect(parts[2]).toBe(testEmailType)
})
it.concurrent(
'should generate different tokens for the same email (due to random salt)',
() => {
const token1 = generateUnsubscribeToken(testEmail, testEmailType)
const token2 = generateUnsubscribeToken(testEmail, testEmailType)
expect(token1).not.toBe(token2)
}
)
it.concurrent('should default to marketing email type', () => {
const token = generateUnsubscribeToken(testEmail)
const parts = token.split(':')
expect(parts[2]).toBe('marketing')
})
it.concurrent('should generate different tokens for different email types', () => {
const marketingToken = generateUnsubscribeToken(testEmail, 'marketing')
const updatesToken = generateUnsubscribeToken(testEmail, 'updates')
expect(marketingToken).not.toBe(updatesToken)
})
})
describe('verifyUnsubscribeToken', () => {
it.concurrent('should verify a valid token', () => {
const token = generateUnsubscribeToken(testEmail, testEmailType)
const result = verifyUnsubscribeToken(testEmail, token)
expect(result.valid).toBe(true)
expect(result.emailType).toBe(testEmailType)
})
it.concurrent('should reject an invalid token', () => {
const invalidToken = 'invalid:token:format'
const result = verifyUnsubscribeToken(testEmail, invalidToken)
expect(result.valid).toBe(false)
expect(result.emailType).toBe('format')
})
it.concurrent('should reject a token for wrong email', () => {
const token = generateUnsubscribeToken(testEmail, testEmailType)
const result = verifyUnsubscribeToken('wrong@example.com', token)
expect(result.valid).toBe(false)
})
it.concurrent('should handle legacy tokens (2 parts) and default to marketing', () => {
const salt = 'abc123'
const secret = 'test-secret-key'
const { createHash } = require('crypto')
const hash = createHash('sha256').update(`${testEmail}:${salt}:${secret}`).digest('hex')
const legacyToken = `${salt}:${hash}`
const result = verifyUnsubscribeToken(testEmail, legacyToken)
expect(result.valid).toBe(true)
expect(result.emailType).toBe('marketing')
})
it.concurrent('should reject malformed tokens', () => {
const malformedTokens = ['', 'single-part', 'too:many:parts:here:invalid', ':empty:parts:']
malformedTokens.forEach((token) => {
const result = verifyUnsubscribeToken(testEmail, token)
expect(result.valid).toBe(false)
})
})
})
describe('isTransactionalEmail', () => {
it.concurrent('should identify transactional emails correctly', () => {
expect(isTransactionalEmail('transactional')).toBe(true)
})
it.concurrent('should identify non-transactional emails correctly', () => {
const nonTransactionalTypes: EmailType[] = ['marketing', 'updates', 'notifications']
nonTransactionalTypes.forEach((type) => {
expect(isTransactionalEmail(type)).toBe(false)
})
})
})
describe('getEmailPreferences', () => {
it('should return email preferences for a user', async () => {
const mockPreferences = {
unsubscribeAll: false,
unsubscribeMarketing: true,
}
mockDb.select.mockReturnValue({
from: vi.fn().mockReturnValue({
leftJoin: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([{ emailPreferences: mockPreferences }]),
}),
}),
}),
})
const result = await getEmailPreferences(testEmail)
expect(result).toEqual(mockPreferences)
})
it('should return null when user is not found', async () => {
mockDb.select.mockReturnValue({
from: vi.fn().mockReturnValue({
leftJoin: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([]),
}),
}),
}),
})
const result = await getEmailPreferences(testEmail)
expect(result).toBeNull()
})
it('should return empty object when emailPreferences is null', async () => {
mockDb.select.mockReturnValue({
from: vi.fn().mockReturnValue({
leftJoin: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([{ emailPreferences: null }]),
}),
}),
}),
})
const result = await getEmailPreferences(testEmail)
expect(result).toEqual({})
})
it('should return null on database error', async () => {
mockDb.select.mockReturnValue({
from: vi.fn().mockReturnValue({
leftJoin: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockRejectedValue(new Error('Database connection failed')),
}),
}),
}),
})
const result = await getEmailPreferences(testEmail)
expect(result).toBeNull()
})
})
describe('updateEmailPreferences', () => {
it('should update email preferences for existing user', async () => {
const userId = 'user-123'
mockDb.select.mockReturnValueOnce({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([{ id: userId }]),
}),
}),
})
mockDb.select.mockReturnValueOnce({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([{ emailPreferences: { unsubscribeAll: false } }]),
}),
}),
})
mockDb.insert.mockReturnValue({
values: vi.fn().mockReturnValue({
onConflictDoUpdate: vi.fn().mockResolvedValue(undefined),
}),
})
const result = await updateEmailPreferences(testEmail, { unsubscribeMarketing: true })
expect(result).toBe(true)
expect(mockDb.insert).toHaveBeenCalled()
})
it('should return false when user is not found', async () => {
mockDb.select.mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([]),
}),
}),
})
const result = await updateEmailPreferences(testEmail, { unsubscribeMarketing: true })
expect(result).toBe(false)
})
it('should merge with existing preferences', async () => {
const userId = 'user-123'
const existingPrefs = { unsubscribeAll: false, unsubscribeUpdates: true }
mockDb.select.mockReturnValueOnce({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([{ id: userId }]),
}),
}),
})
mockDb.select.mockReturnValueOnce({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([{ emailPreferences: existingPrefs }]),
}),
}),
})
const mockInsertValues = vi.fn().mockReturnValue({
onConflictDoUpdate: vi.fn().mockResolvedValue(undefined),
})
mockDb.insert.mockReturnValue({
values: mockInsertValues,
})
await updateEmailPreferences(testEmail, { unsubscribeMarketing: true })
expect(mockInsertValues).toHaveBeenCalledWith(
expect.objectContaining({
emailPreferences: {
unsubscribeAll: false,
unsubscribeUpdates: true,
unsubscribeMarketing: true,
},
})
)
})
it('should return false on database error', async () => {
mockDb.select.mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockRejectedValue(new Error('Database error')),
}),
}),
})
const result = await updateEmailPreferences(testEmail, { unsubscribeMarketing: true })
expect(result).toBe(false)
})
})
describe('isUnsubscribed', () => {
it('should return false when user has no preferences', async () => {
mockDb.select.mockReturnValue({
from: vi.fn().mockReturnValue({
leftJoin: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([]),
}),
}),
}),
})
const result = await isUnsubscribed(testEmail, 'marketing')
expect(result).toBe(false)
})
it('should return true when unsubscribeAll is true', async () => {
mockDb.select.mockReturnValue({
from: vi.fn().mockReturnValue({
leftJoin: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([{ emailPreferences: { unsubscribeAll: true } }]),
}),
}),
}),
})
const result = await isUnsubscribed(testEmail, 'marketing')
expect(result).toBe(true)
})
it('should return true when specific type is unsubscribed', async () => {
mockDb.select.mockReturnValue({
from: vi.fn().mockReturnValue({
leftJoin: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi
.fn()
.mockResolvedValue([
{ emailPreferences: { unsubscribeMarketing: true, unsubscribeUpdates: false } },
]),
}),
}),
}),
})
const resultMarketing = await isUnsubscribed(testEmail, 'marketing')
expect(resultMarketing).toBe(true)
})
it('should return false when specific type is not unsubscribed', async () => {
mockDb.select.mockReturnValue({
from: vi.fn().mockReturnValue({
leftJoin: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi
.fn()
.mockResolvedValue([
{ emailPreferences: { unsubscribeMarketing: false, unsubscribeUpdates: true } },
]),
}),
}),
}),
})
const result = await isUnsubscribed(testEmail, 'marketing')
expect(result).toBe(false)
})
it('should check updates unsubscribe status', async () => {
mockDb.select.mockReturnValue({
from: vi.fn().mockReturnValue({
leftJoin: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi
.fn()
.mockResolvedValue([{ emailPreferences: { unsubscribeUpdates: true } }]),
}),
}),
}),
})
const result = await isUnsubscribed(testEmail, 'updates')
expect(result).toBe(true)
})
it('should check notifications unsubscribe status', async () => {
mockDb.select.mockReturnValue({
from: vi.fn().mockReturnValue({
leftJoin: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi
.fn()
.mockResolvedValue([{ emailPreferences: { unsubscribeNotifications: true } }]),
}),
}),
}),
})
const result = await isUnsubscribed(testEmail, 'notifications')
expect(result).toBe(true)
})
it('should return false for unknown email type', async () => {
mockDb.select.mockReturnValue({
from: vi.fn().mockReturnValue({
leftJoin: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([{ emailPreferences: {} }]),
}),
}),
}),
})
const result = await isUnsubscribed(testEmail, 'all')
expect(result).toBe(false)
})
it('should return false on database error', async () => {
mockDb.select.mockReturnValue({
from: vi.fn().mockReturnValue({
leftJoin: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockRejectedValue(new Error('Database error')),
}),
}),
}),
})
const result = await isUnsubscribed(testEmail, 'marketing')
expect(result).toBe(false)
})
})
describe('unsubscribeFromAll', () => {
it('should call updateEmailPreferences with unsubscribeAll: true', async () => {
const userId = 'user-123'
mockDb.select.mockReturnValueOnce({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([{ id: userId }]),
}),
}),
})
mockDb.select.mockReturnValueOnce({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([{ emailPreferences: {} }]),
}),
}),
})
const mockInsertValues = vi.fn().mockReturnValue({
onConflictDoUpdate: vi.fn().mockResolvedValue(undefined),
})
mockDb.insert.mockReturnValue({
values: mockInsertValues,
})
const result = await unsubscribeFromAll(testEmail)
expect(result).toBe(true)
expect(mockInsertValues).toHaveBeenCalledWith(
expect.objectContaining({
emailPreferences: expect.objectContaining({ unsubscribeAll: true }),
})
)
})
})
describe('resubscribe', () => {
it('should reset all unsubscribe flags to false', async () => {
const userId = 'user-123'
mockDb.select.mockReturnValueOnce({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([{ id: userId }]),
}),
}),
})
mockDb.select.mockReturnValueOnce({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([
{
emailPreferences: {
unsubscribeAll: true,
unsubscribeMarketing: true,
unsubscribeUpdates: true,
unsubscribeNotifications: true,
},
},
]),
}),
}),
})
const mockInsertValues = vi.fn().mockReturnValue({
onConflictDoUpdate: vi.fn().mockResolvedValue(undefined),
})
mockDb.insert.mockReturnValue({
values: mockInsertValues,
})
const result = await resubscribe(testEmail)
expect(result).toBe(true)
expect(mockInsertValues).toHaveBeenCalledWith(
expect.objectContaining({
emailPreferences: {
unsubscribeAll: false,
unsubscribeMarketing: false,
unsubscribeUpdates: false,
unsubscribeNotifications: false,
},
})
)
})
})
})