- createSubscriber: SHA256-Hash, DOI-Token, Consent-IP/UA - confirmDoi: setzt status=active, löscht Token - deleteSubscriber: DSGVO Art. 17 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
133 lines
4.6 KiB
TypeScript
133 lines
4.6 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
|
|
const mockClient = vi.hoisted(() => ({
|
|
execute: vi.fn(),
|
|
query: vi.fn(),
|
|
}))
|
|
|
|
vi.mock('./tenant', () => ({
|
|
withTenant: vi.fn((_, fn) => fn(mockClient)),
|
|
}))
|
|
|
|
import { createSubscriber, confirmDoi, deleteSubscriber } from './subscribers'
|
|
|
|
const mockRow = {
|
|
id: 'sub-uuid-1',
|
|
email: 'test@example.com',
|
|
email_hash: 'abc123hash',
|
|
status: 'pending',
|
|
list_id: null,
|
|
doi_token: 'token-uuid',
|
|
doi_requested_at: new Date('2026-04-17').toISOString(),
|
|
doi_confirmed_at: null,
|
|
consent_ip: '1.2.3.4',
|
|
consent_user_agent: 'Mozilla/5.0',
|
|
created_at: new Date('2026-04-17').toISOString(),
|
|
updated_at: new Date('2026-04-17').toISOString(),
|
|
}
|
|
|
|
describe('createSubscriber', () => {
|
|
beforeEach(() => mockClient.query.mockReset())
|
|
|
|
it('legt Subscriber im status=pending an', async () => {
|
|
mockClient.query.mockResolvedValueOnce([mockRow])
|
|
const result = await createSubscriber('tenant1', {
|
|
email: 'test@example.com',
|
|
consentIp: '1.2.3.4',
|
|
consentUserAgent: 'Mozilla/5.0',
|
|
})
|
|
expect(result.ok).toBe(true)
|
|
if (result.ok) expect(result.data.status).toBe('pending')
|
|
})
|
|
|
|
it('speichert email_hash statt Klartext in Query-Params', async () => {
|
|
mockClient.query.mockResolvedValueOnce([mockRow])
|
|
await createSubscriber('tenant1', {
|
|
email: 'Test@Example.com',
|
|
consentIp: '1.2.3.4',
|
|
consentUserAgent: 'UA',
|
|
})
|
|
const params = mockClient.query.mock.calls[0][1] as unknown[]
|
|
const hashParam = params.find((p) => typeof p === 'string' && /^[a-f0-9]{64}$/.test(p))
|
|
expect(hashParam).toBeDefined()
|
|
})
|
|
|
|
it('sendet Klartext-E-Mail nie als einzigen Parameter', async () => {
|
|
mockClient.query.mockResolvedValueOnce([mockRow])
|
|
await createSubscriber('tenant1', {
|
|
email: 'secret@example.com',
|
|
consentIp: '1.2.3.4',
|
|
consentUserAgent: 'UA',
|
|
})
|
|
const params = mockClient.query.mock.calls[0][1] as unknown[]
|
|
// E-Mail darf vorkommen (für SMTP-Zwecke im subscribers-Record),
|
|
// aber der Hash muss ebenfalls vorhanden sein
|
|
const hasHash = params.some((p) => typeof p === 'string' && /^[a-f0-9]{64}$/.test(p))
|
|
expect(hasHash).toBe(true)
|
|
})
|
|
|
|
it('speichert consentIp und consentUserAgent', async () => {
|
|
mockClient.query.mockResolvedValueOnce([mockRow])
|
|
await createSubscriber('tenant1', {
|
|
email: 'test@example.com',
|
|
consentIp: '9.9.9.9',
|
|
consentUserAgent: 'TestAgent',
|
|
})
|
|
const params = mockClient.query.mock.calls[0][1] as unknown[]
|
|
expect(params).toContain('9.9.9.9')
|
|
expect(params).toContain('TestAgent')
|
|
})
|
|
|
|
it('gibt err zurück wenn E-Mail bereits registriert (ON CONFLICT)', async () => {
|
|
mockClient.query.mockResolvedValueOnce([])
|
|
const result = await createSubscriber('tenant1', {
|
|
email: 'exists@example.com',
|
|
consentIp: '1.2.3.4',
|
|
consentUserAgent: 'UA',
|
|
})
|
|
expect(result.ok).toBe(false)
|
|
if (!result.ok) expect(result.error.message).toContain('bereits registriert')
|
|
})
|
|
})
|
|
|
|
describe('confirmDoi', () => {
|
|
beforeEach(() => mockClient.query.mockReset())
|
|
|
|
it('setzt status=active bei gültigem Token', async () => {
|
|
mockClient.query.mockResolvedValueOnce([{ ...mockRow, status: 'active', doi_token: null, doi_confirmed_at: new Date().toISOString() }])
|
|
const result = await confirmDoi('tenant1', 'valid-token')
|
|
expect(result.ok).toBe(true)
|
|
if (result.ok) expect(result.data.status).toBe('active')
|
|
})
|
|
|
|
it('gibt err zurück bei ungültigem Token', async () => {
|
|
mockClient.query.mockResolvedValueOnce([])
|
|
const result = await confirmDoi('tenant1', 'wrong-token')
|
|
expect(result.ok).toBe(false)
|
|
if (!result.ok) expect(result.error.message).toContain('Ungültiger')
|
|
})
|
|
|
|
it('löscht doi_token nach Bestätigung', async () => {
|
|
mockClient.query.mockResolvedValueOnce([{ ...mockRow, status: 'active', doi_token: null }])
|
|
const result = await confirmDoi('tenant1', 'valid-token')
|
|
if (result.ok) expect(result.data.doiToken).toBeNull()
|
|
})
|
|
})
|
|
|
|
describe('deleteSubscriber', () => {
|
|
beforeEach(() => mockClient.query.mockReset())
|
|
|
|
it('löscht Subscriber und gibt ok zurück', async () => {
|
|
mockClient.query.mockResolvedValueOnce([{ id: 'sub-uuid-1' }])
|
|
const result = await deleteSubscriber('tenant1', 'sub-uuid-1')
|
|
expect(result.ok).toBe(true)
|
|
})
|
|
|
|
it('gibt err zurück wenn Subscriber nicht existiert', async () => {
|
|
mockClient.query.mockResolvedValueOnce([])
|
|
const result = await deleteSubscriber('tenant1', 'not-found')
|
|
expect(result.ok).toBe(false)
|
|
if (!result.ok) expect(result.error.message).toContain('nicht gefunden')
|
|
})
|
|
})
|