Benachrichtigungen erweitert
Some checks failed
Code Analysis and Production Deploy / analyze (push) Failing after 7m53s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been skipped

Emails korrigiert
This commit is contained in:
Torsten Schulz (local)
2026-06-14 01:05:19 +02:00
parent 4b699de853
commit 77aabef4a9
32 changed files with 646 additions and 920 deletions

116
tests/email-service.spec.ts Normal file
View File

@@ -0,0 +1,116 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import fs from 'fs/promises'
vi.mock('nodemailer', () => {
const sendMail = vi.fn().mockResolvedValue({ messageId: 'test-message' })
const createTransport = vi.fn(() => ({ sendMail }))
return {
default: { createTransport },
createTransport
}
})
vi.mock('../server/utils/auth.js', () => ({
readUsers: vi.fn(),
migrateUserRoles: vi.fn((user) => {
if (!user) return user
if (Array.isArray(user.roles)) return user
if (user.role) {
user.roles = [user.role]
delete user.role
} else {
user.roles = ['mitglied']
}
return user
}),
isHiddenUser: vi.fn(user => user?.hidden === true || user?.invisible === true || user?.isHidden === true || user?.systemAccount === true || user?.accountType === 'playstore_review')
}))
const nodemailer = await import('nodemailer')
const authUtils = await import('../server/utils/auth.js')
const emailService = await import('../server/utils/email-service.js')
describe('Email service recipients', () => {
beforeEach(() => {
vi.restoreAllMocks()
vi.clearAllMocks()
process.env.SMTP_USER = 'smtp@example.com'
process.env.SMTP_PASS = 'smtp-password'
authUtils.readUsers.mockResolvedValue([])
})
afterEach(() => {
delete process.env.SMTP_USER
delete process.env.SMTP_PASS
delete process.env.NODE_ENV
delete process.env.APP_ENV
delete process.env.DEBUG
})
it('sendet bei DEBUG=FALSE in production an Vorstand statt Entwickleradresse', async () => {
process.env.NODE_ENV = 'production'
process.env.APP_ENV = 'test'
process.env.DEBUG = 'FALSE'
vi.spyOn(fs, 'readFile').mockResolvedValue(JSON.stringify({
vorstand: {
vorsitzender: { email: 'vorstand@example.com' }
}
}))
await emailService.sendRegistrationNotification({
name: 'Max Muster',
email: 'max@example.com',
phone: '069123456'
})
const transporter = nodemailer.default.createTransport.mock.results[0].value
expect(transporter.sendMail).toHaveBeenCalledWith(expect.objectContaining({
to: 'vorstand@example.com'
}))
expect(transporter.sendMail.mock.calls[0][0].to).not.toContain('tsschulz@tsschulz.de')
})
it('bevorzugt aktive Vorstand-Benutzer vor config.json', async () => {
process.env.NODE_ENV = 'production'
process.env.DEBUG = 'FALSE'
authUtils.readUsers.mockResolvedValue([
{ id: '1', email: 'rolle-vorstand@example.com', roles: ['vorstand'], active: true },
{ id: '2', email: 'inaktiv@example.com', roles: ['vorstand'], active: false },
{ id: '3', email: 'mitglied@example.com', roles: ['mitglied'], active: true }
])
vi.spyOn(fs, 'readFile').mockResolvedValue(JSON.stringify({
vorstand: {
vorsitzender: { email: 'config-vorstand@example.com' }
}
}))
await emailService.sendRegistrationNotification({
name: 'Max Muster',
email: 'max@example.com'
})
const transporter = nodemailer.default.createTransport.mock.results[0].value
const to = transporter.sendMail.mock.calls[0][0].to
expect(to).toBe('rolle-vorstand@example.com')
expect(to).not.toContain('config-vorstand@example.com')
expect(to).not.toContain('inaktiv@example.com')
})
it('sendet nur bei explizitem DEBUG=true an die Entwickleradresse', async () => {
process.env.NODE_ENV = 'production'
process.env.DEBUG = 'true'
vi.spyOn(fs, 'readFile').mockResolvedValue(JSON.stringify({
vorstand: {
vorsitzender: { email: 'vorstand@example.com' }
}
}))
await emailService.sendRegistrationNotification({
name: 'Max Muster',
email: 'max@example.com'
})
const transporter = nodemailer.default.createTransport.mock.results[0].value
expect(transporter.sendMail.mock.calls[0][0].to).toBe('tsschulz@tsschulz.de')
})
})

View File

@@ -51,12 +51,13 @@ import membersGetHandler from '../server/api/members.get.js'
import membersPostHandler from '../server/api/members.post.js'
import membersDeleteHandler from '../server/api/members.delete.js'
import membersBulkHandler from '../server/api/members/bulk.post.js'
import membersBulkHandler from '../server/api/members/bulk.post.js'
import toggleMannschaftsspielerHandler from '../server/api/members/toggle-mannschaftsspieler.post.js'
describe('Members API Endpoints', () => {
beforeEach(() => {
vi.clearAllMocks()
authUtils.readUsers.mockResolvedValue([])
memberUtils.readMembers.mockResolvedValue([])
})
describe('GET /api/members', () => {
@@ -100,6 +101,38 @@ describe('Members API Endpoints', () => {
expect(response.members[0].name).toBe('Anna Muster')
})
it('liefert Geburtstags-Sichtbarkeit für Admin/Vorstand-Bearbeitung', async () => {
const event = createEvent({ cookies: { auth_token: 'token' } })
authUtils.verifyToken.mockReturnValue({ id: '1' })
memberUtils.readMembers.mockResolvedValue([
{ id: 'm1', firstName: 'Anna', lastName: 'Muster', geburtsdatum: '2000-01-01', visibility: { showBirthday: false } }
])
authUtils.readUsers.mockResolvedValue([])
authUtils.getUserFromToken.mockResolvedValue({ id: '1', role: 'admin' })
const response = await membersGetHandler(event)
expect(response.members).toHaveLength(1)
expect(response.members[0].showBirthday).toBe(false)
})
it('uebernimmt Geburtstags-Sichtbarkeit vom Login-Benutzer beim Merge', async () => {
const event = createEvent({ cookies: { auth_token: 'token' } })
authUtils.verifyToken.mockReturnValue({ id: '1' })
memberUtils.readMembers.mockResolvedValue([
{ id: 'm1', firstName: 'Anna', lastName: 'Muster', email: 'anna@club.de', geburtsdatum: '2000-01-01', visibility: { showBirthday: true } }
])
authUtils.readUsers.mockResolvedValue([
{ id: 'u1', name: 'Anna Muster', email: 'anna@club.de', active: true, visibility: { showBirthday: false } }
])
authUtils.getUserFromToken.mockResolvedValue({ id: '1', role: 'admin' })
const response = await membersGetHandler(event)
expect(response.members).toHaveLength(1)
expect(response.members[0].showBirthday).toBe(false)
})
it('blendet unsichtbare Playstore-Benutzer und passende manuelle Einträge aus', async () => {
const event = createEvent({ cookies: { auth_token: 'token' } })
@@ -159,6 +192,8 @@ describe('Members API Endpoints', () => {
const event = createEvent({ cookies: { auth_token: 'token' } })
mockSuccessReadBody(baseBody)
authUtils.getUserFromToken.mockResolvedValue({ id: '2', role: 'admin' })
authUtils.readUsers.mockResolvedValue([])
memberUtils.readMembers.mockResolvedValue([])
memberUtils.saveMember.mockResolvedValue(true)
const response = await membersPostHandler(event)
@@ -168,6 +203,76 @@ describe('Members API Endpoints', () => {
}))
})
it('speichert Geburtstags-Sichtbarkeit für manuelle Mitglieder', async () => {
const event = createEvent({ cookies: { auth_token: 'token' } })
mockSuccessReadBody({ ...baseBody, showBirthday: false })
authUtils.getUserFromToken.mockResolvedValue({ id: '2', role: 'admin' })
authUtils.readUsers.mockResolvedValue([])
memberUtils.readMembers.mockResolvedValue([
{ id: 'manual-1', firstName: 'Lisa', lastName: 'Beispiel', email: 'lisa@example.com', visibility: { showBirthday: true } }
])
memberUtils.saveMember.mockResolvedValue(true)
const response = await membersPostHandler(event)
expect(response.success).toBe(true)
expect(memberUtils.saveMember).toHaveBeenCalledWith(expect.objectContaining({
visibility: expect.objectContaining({ showBirthday: false })
}))
expect(authUtils.writeUsers).not.toHaveBeenCalled()
})
it('kann Geburtstags-Sichtbarkeit auch am Login-Benutzer ausschalten', async () => {
const event = createEvent({ cookies: { auth_token: 'token' } })
mockSuccessReadBody({
id: 'user-1',
...baseBody,
email: 'lisa@example.com',
visibility: { showBirthday: false }
})
authUtils.getUserFromToken.mockResolvedValue({ id: '2', role: 'vorstand' })
memberUtils.readMembers.mockResolvedValue([])
authUtils.readUsers.mockResolvedValue([
{ id: 'user-1', email: 'lisa@example.com', visibility: { showBirthday: true, showEmail: true } }
])
authUtils.writeUsers.mockResolvedValue(undefined)
memberUtils.saveMember.mockResolvedValue(true)
const response = await membersPostHandler(event)
expect(response.success).toBe(true)
expect(authUtils.writeUsers).toHaveBeenCalledWith([
expect.objectContaining({
id: 'user-1',
visibility: expect.objectContaining({ showBirthday: false, showEmail: true })
})
])
})
it('darf Geburtstags-Sichtbarkeit nicht für Login-Benutzer einschalten', async () => {
const event = createEvent({ cookies: { auth_token: 'token' } })
mockSuccessReadBody({
id: 'user-1',
...baseBody,
email: 'lisa@example.com',
visibility: { showBirthday: true }
})
authUtils.getUserFromToken.mockResolvedValue({ id: '2', role: 'vorstand' })
memberUtils.readMembers.mockResolvedValue([])
authUtils.readUsers.mockResolvedValue([
{ id: 'user-1', email: 'lisa@example.com', visibility: { showBirthday: false, showEmail: true } }
])
memberUtils.saveMember.mockResolvedValue(true)
const response = await membersPostHandler(event)
expect(response.success).toBe(true)
expect(memberUtils.saveMember).toHaveBeenCalledWith(expect.objectContaining({
visibility: expect.objectContaining({ showBirthday: false })
}))
expect(authUtils.writeUsers).not.toHaveBeenCalled()
})
it('erlaubt vorstand beim Speichern', async () => {
const event = createEvent({ cookies: { auth_token: 'token' } })
mockSuccessReadBody(baseBody)
@@ -187,6 +292,7 @@ describe('Members API Endpoints', () => {
email: 'lisa@example.com'
})
authUtils.getUserFromToken.mockResolvedValue({ id: '3', role: 'vorstand' })
authUtils.readUsers.mockResolvedValue([])
memberUtils.saveMember.mockResolvedValue(true)
const response = await membersPostHandler(event)

View File

@@ -0,0 +1,93 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import fs from 'fs/promises'
vi.mock('../server/utils/auth.js', () => ({
readUsers: vi.fn(),
isHiddenUser: vi.fn(user => user?.hidden === true || user?.invisible === true || user?.isHidden === true || user?.systemAccount === true)
}))
vi.mock('../server/utils/members.js', () => ({
readMembers: vi.fn()
}))
vi.mock('../server/utils/termine.js', () => ({
readTermine: vi.fn().mockResolvedValue([])
}))
vi.mock('../server/utils/news.js', () => ({
readNews: vi.fn().mockResolvedValue([])
}))
vi.mock('../server/utils/spielplan-data.js', () => ({
getDefaultSpielplanSeason: vi.fn().mockResolvedValue('25--26'),
readSpielplanData: vi.fn().mockResolvedValue({ data: [] })
}))
vi.mock('../server/utils/push-notifications.js', () => ({
sendPushToUsers: vi.fn().mockResolvedValue({ sent: 1, failed: 0, removed: 0, recipients: 1, tokenCount: 1, skipped: false })
}))
vi.mock('../server/utils/logger.js', () => ({
error: vi.fn(),
info: vi.fn(),
warn: vi.fn()
}))
const authUtils = await import('../server/utils/auth.js')
const memberUtils = await import('../server/utils/members.js')
const pushUtils = await import('../server/utils/push-notifications.js')
const { runNotificationSchedulerTick } = await import('../server/utils/notification-scheduler.js')
const schedulerNow = new Date('2026-06-14T07:00:00.000Z')
const recipient = {
id: 'recipient',
name: 'Push Empfaenger',
active: true,
notificationSettings: {
birthdays: true,
notificationTime: '09:00'
}
}
describe('Notification Scheduler', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.spyOn(fs, 'readFile').mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }))
vi.spyOn(fs, 'mkdir').mockResolvedValue(undefined)
vi.spyOn(fs, 'writeFile').mockResolvedValue(undefined)
memberUtils.readMembers.mockResolvedValue([])
authUtils.readUsers.mockResolvedValue([recipient])
})
it('sendet Geburtstags-Push nur fuer Mitglieder mit expliziter Geburtstagsfreigabe', async () => {
memberUtils.readMembers.mockResolvedValue([
{ firstName: 'Erlaubt', lastName: 'Person', active: true, geburtsdatum: '1990-06-14', visibility: { showBirthday: true } },
{ firstName: 'Privat', lastName: 'Person', active: true, geburtsdatum: '1990-06-14', visibility: { showBirthday: false } },
{ firstName: 'Unklar', lastName: 'Person', active: true, geburtsdatum: '1990-06-14' }
])
await runNotificationSchedulerTick(schedulerNow)
expect(pushUtils.sendPushToUsers).toHaveBeenCalledTimes(1)
expect(pushUtils.sendPushToUsers).toHaveBeenCalledWith(expect.objectContaining({
title: 'Geburtstage heute',
body: 'Erlaubt Person hat heute Geburtstag.',
data: { type: 'birthdays', date: '2026-06-14' }
}))
})
it('nennt bei mehreren Geburtstags-Pushes nur Namen und kein Alter', async () => {
memberUtils.readMembers.mockResolvedValue([
{ firstName: 'Anna', lastName: 'Beispiel', active: true, geburtsdatum: '1980-06-14', visibility: { showBirthday: true } },
{ firstName: 'Bert', lastName: 'Beispiel', active: true, geburtsdatum: '2010-06-14', visibility: { showBirthday: true } }
])
await runNotificationSchedulerTick(schedulerNow)
const payload = pushUtils.sendPushToUsers.mock.calls[0][0]
expect(payload.body).toBe('Geburtstage heute: Anna Beispiel, Bert Beispiel.')
expect(payload.body).not.toMatch(/\b\d+\b/)
expect(payload.body).not.toContain('Jahre')
})
})

View File

@@ -16,8 +16,25 @@ vi.mock('../server/utils/news.js', () => ({
readNews: vi.fn()
}))
vi.mock('../server/utils/auth.js', () => ({
readUsers: vi.fn(),
migrateUserRoles: vi.fn((user) => {
if (!user) return user
if (Array.isArray(user.roles)) return user
if (user.role) {
user.roles = [user.role]
delete user.role
} else {
user.roles = ['mitglied']
}
return user
}),
isHiddenUser: vi.fn(user => user?.hidden === true || user?.invisible === true || user?.isHidden === true || user?.systemAccount === true || user?.accountType === 'playstore_review')
}))
const nodemailer = await import('nodemailer')
const newsUtils = await import('../server/utils/news.js')
const authUtils = await import('../server/utils/auth.js')
import contactHandler from '../server/api/contact.post.js'
import galerieHandler from '../server/api/galerie.get.js'
@@ -29,14 +46,17 @@ describe('Öffentliche API-Endpunkte', () => {
afterEach(() => {
delete process.env.NODE_ENV
delete process.env.APP_ENV
delete process.env.DEBUG
})
beforeEach(() => {
// Setze SMTP-Credentials für Tests
process.env.SMTP_USER = 'test@example.com'
process.env.SMTP_PASS = 'test-password'
authUtils.readUsers.mockResolvedValue([])
vi.restoreAllMocks()
vi.clearAllMocks()
authUtils.readUsers.mockResolvedValue([])
})
describe('POST /api/contact', () => {
@@ -78,6 +98,53 @@ describe('Öffentliche API-Endpunkte', () => {
to: 'tsschulz@tsschulz.de'
}))
})
it('sendet bei DEBUG=FALSE an konfigurierte Empfänger', async () => {
process.env.NODE_ENV = 'production'
process.env.APP_ENV = 'test'
process.env.DEBUG = 'FALSE'
vi.spyOn(fs, 'readFile').mockResolvedValue(JSON.stringify({
vorstand: {
vorsitzender: { email: 'vorstand@example.com' }
}
}))
const event = createEvent()
mockSuccessReadBody({ name: 'Max', email: 'max@example.com', subject: 'Frage', message: 'Hallo' })
await contactHandler(event)
const transporter = nodemailer.default.createTransport.mock.results[0].value
const to = transporter.sendMail.mock.calls[0][0].to
expect(to).toContain('vorstand@example.com')
expect(to).not.toContain('tsschulz@tsschulz.de')
})
it('bevorzugt aktive Vorstand-Benutzer vor config.json', async () => {
process.env.NODE_ENV = 'production'
process.env.DEBUG = 'FALSE'
authUtils.readUsers.mockResolvedValue([
{ id: '1', email: 'rolle-vorstand@example.com', roles: ['vorstand'], active: true },
{ id: '2', email: 'hidden@example.com', roles: ['vorstand'], active: true, hidden: true },
{ id: '3', email: 'trainer@example.com', roles: ['trainer'], active: true }
])
vi.spyOn(fs, 'readFile').mockResolvedValue(JSON.stringify({
vorstand: {
vorsitzender: { email: 'config-vorstand@example.com' }
}
}))
const event = createEvent()
mockSuccessReadBody({ name: 'Max', email: 'max@example.com', subject: 'Frage', message: 'Hallo' })
await contactHandler(event)
const transporter = nodemailer.default.createTransport.mock.results[0].value
const to = transporter.sendMail.mock.calls[0][0].to
expect(to).toContain('rolle-vorstand@example.com')
expect(to).not.toContain('config-vorstand@example.com')
expect(to).not.toContain('hidden@example.com')
})
})
describe('GET /api/galerie', () => {