3 Commits

Author SHA1 Message Date
Paddy fa34a6c9fb Add posterImageUrl to events
Concert posters already live on the public website, so events store a URL
to an existing image rather than hosting an uploaded file.
2026-08-18 22:07:31 +02:00
Paddy c2ddb11c4c Fix silent newsletter validation drops, surface skipped-sync visibility, consolidate duplicated helpers
Three fixes from the earlier review, plus cleanup:

- submissions.service.ts: a newsletter opt-in present but failing
  validation (e.g. malformed email) was silently dropped with no signal
  to the client - the rest of the submission saved, but the visitor had
  no way to know their newsletter signup didn't go through. Added
  newsletterDropped to the submit response so the frontend can tell them.

- reports.admin.service.ts: the newsletter summary tracked
  total/sent/pending/failed but silently omitted SKIPPED (stub-mode)
  signups from any bucket - every current signup showed total>0 with
  every bucket reading 0, indistinguishable from "we don't know what
  happened". Added a skipped count.

- Consolidated two things duplicated across the module: sendServerError
  (reimplemented ~11 times, three of those as identical local copies of
  the same function) into feedback.errors.ts, and formatDatetime/
  toMysqlDatetime (the same local-time formatting logic under two names,
  in csv.service.ts and events.admin.service.ts respectively) into
  feedback.dates.ts.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 17:45:37 +02:00
Paddy 080b987914 Add unit tests for the Salesforce integration
Covers integrations/salesforce.service.ts: disabled-mode logging-only
path, signup-not-found, success (token fetch + POST + mark SENT), token
reuse across calls, retry-once-on-401, failure marks FAILED with the
error message, and a missing-client-credentials configuration error.
100% statement coverage on the file.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-15 17:45:06 +02:00
23 changed files with 304 additions and 134 deletions
@@ -0,0 +1,9 @@
-- Nachklang e.V. Feedback module — adds a per-event poster image URL.
-- Apply manually against the FEEDBACK_DB database, after 001_init.sql:
-- mysql -h <DB_HOST> -u <DB_USER> -p <FEEDBACK_DB> < 002_add_poster_image_url.sql
--
-- Stores a URL only (e.g. an existing nachklang.art poster image) rather
-- than an uploaded file — the concert posters already live on the public
-- website, so there is no need for the feedback app to host its own copy.
ALTER TABLE events
ADD COLUMN poster_image_url VARCHAR(500) NULL AFTER intro_text;
+2 -9
View File
@@ -2,10 +2,9 @@
* Required External Modules and Interfaces
*/
import express, {Request, Response} from 'express';
import {Guid} from 'guid-typescript';
import logger from '../../middleware/logger';
import {publicRouter} from './public/public.router';
import {adminRouter} from './admin/admin.router';
import {sendServerError} from './feedback.errors';
/**
* Router Definition
@@ -52,12 +51,6 @@ feedbackRouter.get('/', async (req: Request, res: Response) => {
try {
res.status(200).send('Nachklang e.V. Feedback API Endpoint');
} catch (e: any) {
let errorGuid = Guid.create().toString();
logger.error('Error handling a request: ' + e.message, {reference: errorGuid});
res.status(500).send({
'status': 'PROCESSING_ERROR',
'message': 'Internal Server Error. Try again later.',
'reference': errorGuid
});
sendServerError(res, e);
}
});
@@ -33,6 +33,9 @@
* feedbackDeadline:
* type: string
* format: date-time
* posterImageUrl:
* type: string
* nullable: true
* isPublished:
* type: boolean
* submissionCount:
@@ -87,6 +90,7 @@ export interface EventAdminSummary {
subtitle: string | null;
eventDate: string;
feedbackDeadline: string;
posterImageUrl: string | null;
isPublished: boolean;
submissionCount: number;
}
@@ -110,6 +114,7 @@ export interface CreateEventInput {
eventDate: string;
feedbackDeadline?: string;
introText?: string;
posterImageUrl?: string;
}
export interface UpdateEventInput {
@@ -119,6 +124,7 @@ export interface UpdateEventInput {
feedbackDeadline?: string;
isPublished?: boolean;
introText?: string;
posterImageUrl?: string;
}
export interface AdminQuestion {
+2 -9
View File
@@ -2,9 +2,8 @@
* Required External Modules and Interfaces
*/
import express, {Request, Response} from 'express';
import {Guid} from 'guid-typescript';
import logger from '../../../middleware/logger';
import {requireAdminAuth} from '../feedback.auth';
import {sendServerError} from '../feedback.errors';
import {eventsAdminRouter} from './events.admin.router';
import {songsAdminRouter} from './songs.admin.router';
import {questionsAdminRouter} from './questions.admin.router';
@@ -81,13 +80,7 @@ adminRouter.delete('/submissions/:submissionId', async (req: Request, res: Respo
}
res.status(204).send();
} catch (e: any) {
let errorGuid = Guid.create().toString();
logger.error('Error handling a request: ' + e.message, {reference: errorGuid});
res.status(500).send({
status: 'PROCESSING_ERROR',
message: 'Internal Server Error. Try again later.',
reference: errorGuid
});
sendServerError(res, e);
}
});
+1 -9
View File
@@ -1,4 +1,5 @@
import {NachklangFeedbackDB} from '../Feedback.db';
import {formatDatetime} from '../feedback.dates';
const CSV_SEPARATOR = ';';
const UTF8_BOM = '';
@@ -23,15 +24,6 @@ export const escapeCsvField = (value: string | number | null | undefined): strin
return str;
};
/** mariadb returns DATETIME columns as JS Date objects - format explicitly,
* otherwise String(date) falls back to the verbose Date.toString() format. */
export const formatDatetime = (value: Date | string | null): string => {
if (!value) return '';
const d = value instanceof Date ? value : new Date(value);
const pad = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
};
const buildCsv = (headers: string[], rows: (string | number | null | undefined)[][]): string => {
const lines = [headers.map(escapeCsvField).join(CSV_SEPARATOR)];
for (const row of rows) {
@@ -2,26 +2,15 @@
* Required External Modules and Interfaces
*/
import express, {Request, Response} from 'express';
import {Guid} from 'guid-typescript';
import logger from '../../../middleware/logger';
import * as EventsAdminService from './events.admin.service';
import * as SongsAdminService from './songs.admin.service';
import {sendServerError} from '../feedback.errors';
/**
* Router Definition
*/
export const eventsAdminRouter = express.Router();
const sendServerError = (res: Response, e: any) => {
let errorGuid = Guid.create().toString();
logger.error('Error handling a request: ' + e.message, {reference: errorGuid});
res.status(500).send({
'status': 'PROCESSING_ERROR',
'message': 'Internal Server Error. Try again later.',
'reference': errorGuid
});
};
/**
* @swagger
* /feedback/admin/events:
@@ -70,6 +59,8 @@ const sendServerError = (res: Response, e: any) => {
* format: date-time
* introText:
* type: string
* posterImageUrl:
* type: string
* responses:
* 201:
* description: Created
@@ -88,13 +79,13 @@ eventsAdminRouter.get('/', async (req: Request, res: Response) => {
eventsAdminRouter.post('/', async (req: Request, res: Response) => {
try {
const {name, subtitle, eventDate, feedbackDeadline, introText} = req.body || {};
const {name, subtitle, eventDate, feedbackDeadline, introText, posterImageUrl} = req.body || {};
if (!name || !eventDate) {
res.status(400).send({status: 'BAD_REQUEST', message: 'name and eventDate are required'});
return;
}
const eventId = await EventsAdminService.createEvent(
{name, subtitle, eventDate, feedbackDeadline, introText},
{name, subtitle, eventDate, feedbackDeadline, introText, posterImageUrl},
res.locals.admin.email
);
res.status(201).send({eventId});
@@ -1,6 +1,7 @@
import {NachklangFeedbackDB} from '../Feedback.db';
import {Song} from '../feedback.interface';
import {CreateEventInput, EventAdminDetail, EventAdminQuestionAssignment, EventAdminSummary, UpdateEventInput} from './admin.interface';
import {formatDatetime} from '../feedback.dates';
const UMLAUT_MAP: Record<string, string> = {
'ä': 'ae', 'ö': 'oe', 'ü': 'ue', 'ß': 'ss',
@@ -37,6 +38,7 @@ const mapSummaryRow = (row: any): EventAdminSummary => ({
subtitle: row.subtitle,
eventDate: row.event_date,
feedbackDeadline: row.feedback_deadline,
posterImageUrl: row.poster_image_url,
isPublished: !!row.is_published,
submissionCount: Number(row.submission_count)
});
@@ -83,11 +85,6 @@ const generateUniqueSlug = async (conn: any, name: string, eventDate: string): P
}
};
const toMysqlDatetime = (d: Date): string => {
const pad = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
};
export const createEvent = async (input: CreateEventInput, createdByEmail: string): Promise<number> => {
let conn = await NachklangFeedbackDB.getConnection();
try {
@@ -98,11 +95,11 @@ export const createEvent = async (input: CreateEventInput, createdByEmail: strin
: computeDefaultDeadline(input.eventDate);
const query = `
INSERT INTO events (slug, name, subtitle, event_date, feedback_deadline, intro_text, created_by_email)
VALUES (?,?,?,?,?,?,?) RETURNING event_id`;
INSERT INTO events (slug, name, subtitle, event_date, feedback_deadline, intro_text, poster_image_url, created_by_email)
VALUES (?,?,?,?,?,?,?,?) RETURNING event_id`;
const res = await conn.query(query, [
slug, input.name, input.subtitle || null, input.eventDate, toMysqlDatetime(deadline),
input.introText || null, createdByEmail
slug, input.name, input.subtitle || null, input.eventDate, formatDatetime(deadline),
input.introText || null, input.posterImageUrl || null, createdByEmail
]);
await conn.commit();
return res[0].event_id;
@@ -158,6 +155,7 @@ export const updateEvent = async (eventId: number, input: UpdateEventInput): Pro
if (input.feedbackDeadline !== undefined) { fields.push('feedback_deadline = ?'); values.push(input.feedbackDeadline); }
if (input.isPublished !== undefined) { fields.push('is_published = ?'); values.push(input.isPublished ? 1 : 0); }
if (input.introText !== undefined) { fields.push('intro_text = ?'); values.push(input.introText); }
if (input.posterImageUrl !== undefined) { fields.push('poster_image_url = ?'); values.push(input.posterImageUrl || null); }
if (fields.length === 0) return true;
@@ -2,25 +2,14 @@
* Required External Modules and Interfaces
*/
import express, {Request, Response} from 'express';
import {Guid} from 'guid-typescript';
import logger from '../../../middleware/logger';
import * as QuestionsAdminService from './questions.admin.service';
import {sendServerError} from '../feedback.errors';
/**
* Router Definition
*/
export const questionsAdminRouter = express.Router();
const sendServerError = (res: Response, e: any) => {
let errorGuid = Guid.create().toString();
logger.error('Error handling a request: ' + e.message, {reference: errorGuid});
res.status(500).send({
'status': 'PROCESSING_ERROR',
'message': 'Internal Server Error. Try again later.',
'reference': errorGuid
});
};
/**
* @swagger
* /feedback/admin/questions:
@@ -58,6 +58,7 @@ export interface EventReport {
sent: number;
pending: number;
failed: number;
skipped: number;
};
}
@@ -2,27 +2,16 @@
* Required External Modules and Interfaces
*/
import express, {Request, Response} from 'express';
import {Guid} from 'guid-typescript';
import logger from '../../../middleware/logger';
import * as ReportsAdminService from './reports.admin.service';
import * as CsvService from './csv.service';
import * as EventsAdminService from './events.admin.service';
import {sendServerError} from '../feedback.errors';
/**
* Router Definition
*/
export const reportsAdminRouter = express.Router();
const sendServerError = (res: Response, e: any) => {
let errorGuid = Guid.create().toString();
logger.error('Error handling a request: ' + e.message, {reference: errorGuid});
res.status(500).send({
'status': 'PROCESSING_ERROR',
'message': 'Internal Server Error. Try again later.',
'reference': errorGuid
});
};
/**
* @swagger
* /feedback/admin/events/{eventId}/report:
@@ -16,7 +16,7 @@ export const aggregateReport = (
submissionStats: {totalSubmissions: number; firstSubmissionAt: string | null; lastSubmissionAt: string | null},
answerRows: AnswerRow[],
guestBookCount: number,
newsletterCounts: {total: number; sent: number; pending: number; failed: number}
newsletterCounts: {total: number; sent: number; pending: number; failed: number; skipped: number}
): EventReport => {
const groupKey = (row: AnswerRow) => `${row.questionId ?? 'null'}::${row.questionLabel}`;
@@ -137,13 +137,14 @@ export const getReport = async (eventId: number): Promise<EventReport | null> =>
`SELECT sync_status, COUNT(*) as cnt FROM newsletter_signups WHERE event_id = ? GROUP BY sync_status`,
[eventId]
);
const newsletterCounts = {total: 0, sent: 0, pending: 0, failed: 0};
const newsletterCounts = {total: 0, sent: 0, pending: 0, failed: 0, skipped: 0};
for (const row of newsletterRows) {
const cnt = Number(row.cnt);
newsletterCounts.total += cnt;
if (row.sync_status === 'SENT') newsletterCounts.sent = cnt;
else if (row.sync_status === 'PENDING') newsletterCounts.pending = cnt;
else if (row.sync_status === 'FAILED') newsletterCounts.failed = cnt;
else if (row.sync_status === 'SKIPPED') newsletterCounts.skipped = cnt;
}
return aggregateReport(
@@ -2,9 +2,8 @@
* Required External Modules and Interfaces
*/
import express, {Request, Response} from 'express';
import {Guid} from 'guid-typescript';
import logger from '../../../middleware/logger';
import * as SongsAdminService from './songs.admin.service';
import {sendServerError} from '../feedback.errors';
/**
* Router Definition
@@ -80,13 +79,7 @@ songsAdminRouter.put('/:songId', async (req: Request, res: Response) => {
}
res.status(200).send({status: 'OK'});
} catch (e: any) {
let errorGuid = Guid.create().toString();
logger.error('Error handling a request: ' + e.message, {reference: errorGuid});
res.status(500).send({
'status': 'PROCESSING_ERROR',
'message': 'Internal Server Error. Try again later.',
'reference': errorGuid
});
sendServerError(res, e);
}
});
@@ -99,12 +92,6 @@ songsAdminRouter.delete('/:songId', async (req: Request, res: Response) => {
}
res.status(204).send();
} catch (e: any) {
let errorGuid = Guid.create().toString();
logger.error('Error handling a request: ' + e.message, {reference: errorGuid});
res.status(500).send({
'status': 'PROCESSING_ERROR',
'message': 'Internal Server Error. Try again later.',
'reference': errorGuid
});
sendServerError(res, e);
}
});
+2 -9
View File
@@ -1,7 +1,6 @@
import express from 'express';
import {Guid} from 'guid-typescript';
import logger from '../../middleware/logger';
import * as UserService from '../calendar/users/users.service';
import {sendServerError} from './feedback.errors';
/**
* This file is the ONLY place in the feedback module that knows how admin
@@ -74,12 +73,6 @@ export const requireAdminAuth: express.RequestHandler = async (req, res, next) =
res.locals.admin = identity;
next();
} catch (e: any) {
let errorGuid = Guid.create().toString();
logger.error('Error handling a request: ' + e.message, {reference: errorGuid});
res.status(500).send({
'status': 'PROCESSING_ERROR',
'message': 'Internal Server Error. Try again later.',
'reference': errorGuid
});
sendServerError(res, e);
}
};
+13
View File
@@ -0,0 +1,13 @@
/**
* Formats a Date using its local getters (not toISOString/UTC), so the
* wall-clock time the server is running in is what gets stored/displayed -
* never silently shifted by a UTC conversion. Used both for MySQL DATETIME
* literals (events.admin.service.ts) and CSV export (csv.service.ts): same
* requirement, same format, in either context.
*/
export const formatDatetime = (value: Date | string | null): string => {
if (!value) return '';
const d = value instanceof Date ? value : new Date(value);
const pad = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
};
+18
View File
@@ -0,0 +1,18 @@
import {Response} from 'express';
import {Guid} from 'guid-typescript';
import logger from '../../middleware/logger';
/**
* The feedback module's standard catch-block response: log with a
* reference guid, never leak the real error message to the client. Every
* router in this module follows this exact convention (see CLAUDE.md).
*/
export const sendServerError = (res: Response, e: any): void => {
const errorGuid = Guid.create().toString();
logger.error('Error handling a request: ' + e.message, {reference: errorGuid});
res.status(500).send({
status: 'PROCESSING_ERROR',
message: 'Internal Server Error. Try again later.',
reference: errorGuid
});
};
@@ -62,6 +62,10 @@
* feedbackDeadline:
* type: string
* format: date-time
* posterImageUrl:
* type: string
* nullable: true
* example: "https://www.nachklang.art/img/nk/image-20260727-214855-851.jpeg"
* EventConfig:
* allOf:
* - $ref: '#/components/schemas/EventSummary'
@@ -116,6 +120,7 @@ export interface EventSummary {
subtitle: string | null;
eventDate: string;
feedbackDeadline: string;
posterImageUrl: string | null;
}
export interface EventConfig extends EventSummary {
@@ -9,7 +9,7 @@ export const getEligibleEvents = async (): Promise<EventSummary[]> => {
let conn = await NachklangFeedbackDB.getConnection();
try {
const query = `
SELECT slug, name, subtitle, event_date, feedback_deadline
SELECT slug, name, subtitle, event_date, feedback_deadline, poster_image_url
FROM events
WHERE is_published = 1 AND event_date <= CURDATE() AND feedback_deadline >= NOW()
ORDER BY event_date DESC`;
@@ -19,7 +19,8 @@ export const getEligibleEvents = async (): Promise<EventSummary[]> => {
name: row.name,
subtitle: row.subtitle,
eventDate: row.event_date,
feedbackDeadline: row.feedback_deadline
feedbackDeadline: row.feedback_deadline,
posterImageUrl: row.poster_image_url
}));
} finally {
await conn.end();
@@ -41,7 +42,7 @@ export const getEventConfigBySlug = async (slug: string): Promise<EventLookupRes
let conn = await NachklangFeedbackDB.getConnection();
try {
const eventQuery = `
SELECT event_id, slug, name, subtitle, event_date, feedback_deadline, intro_text, is_published
SELECT event_id, slug, name, subtitle, event_date, feedback_deadline, intro_text, poster_image_url, is_published
FROM events WHERE slug = ?`;
const eventRows = await conn.query(eventQuery, [slug]);
if (eventRows.length === 0) {
@@ -91,6 +92,7 @@ export const getEventConfigBySlug = async (slug: string): Promise<EventLookupRes
subtitle: eventRow.subtitle,
eventDate: eventRow.event_date,
feedbackDeadline: eventRow.feedback_deadline,
posterImageUrl: eventRow.poster_image_url,
introText: eventRow.intro_text,
songs,
questions
+6 -24
View File
@@ -2,11 +2,11 @@
* Required External Modules and Interfaces
*/
import express, {Request, Response} from 'express';
import {Guid} from 'guid-typescript';
import logger from '../../../middleware/logger';
import {getEligibleEvents, getEventConfigBySlug} from './events.public.service';
import {submitFeedback} from './submissions.service';
import {hashIp, isRateLimited, recordSubmission} from '../feedback.ratelimit';
import {sendServerError} from '../feedback.errors';
/**
* Router Definition
@@ -51,13 +51,7 @@ publicRouter.get('/events', async (req: Request, res: Response) => {
const events = await getEligibleEvents();
res.status(200).send(events);
} catch (e: any) {
let errorGuid = Guid.create().toString();
logger.error('Error handling a request: ' + e.message, {reference: errorGuid});
res.status(500).send({
'status': 'PROCESSING_ERROR',
'message': 'Internal Server Error. Try again later.',
'reference': errorGuid
});
sendServerError(res, e);
}
});
@@ -106,13 +100,7 @@ publicRouter.get('/events/:slug', async (req: Request, res: Response) => {
}
res.status(200).send(result.event);
} catch (e: any) {
let errorGuid = Guid.create().toString();
logger.error('Error handling a request: ' + e.message, {reference: errorGuid});
res.status(500).send({
'status': 'PROCESSING_ERROR',
'message': 'Internal Server Error. Try again later.',
'reference': errorGuid
});
sendServerError(res, e);
}
});
@@ -166,7 +154,7 @@ publicRouter.post('/events/:slug/submissions', async (req: Request, res: Respons
// nothing, stay silent about it having failed.
if (isHoneypotTriggered(body)) {
logger.info('Feedback honeypot triggered', {slug: req.params.slug});
res.status(201).send({submissionId: -1});
res.status(201).send({submissionId: -1, newsletterDropped: false});
return;
}
@@ -196,16 +184,10 @@ publicRouter.post('/events/:slug/submissions', async (req: Request, res: Respons
res.status(400).send({status: 'EMPTY_SUBMISSION'});
return;
case 'OK':
res.status(201).send({submissionId: result.submissionId});
res.status(201).send({submissionId: result.submissionId, newsletterDropped: result.newsletterDropped});
return;
}
} catch (e: any) {
let errorGuid = Guid.create().toString();
logger.error('Error handling a request: ' + e.message, {reference: errorGuid});
res.status(500).send({
'status': 'PROCESSING_ERROR',
'message': 'Internal Server Error. Try again later.',
'reference': errorGuid
});
sendServerError(res, e);
}
});
@@ -62,6 +62,9 @@
* submissionId:
* type: integer
* example: 91
* newsletterDropped:
* type: boolean
* description: True if the newsletter opt-in was present but failed validation (e.g. a malformed email) - the rest of the submission still saved.
*/
export interface RatingInput {
@@ -137,7 +137,7 @@ export const validateNewsletter = (input?: NewsletterInput): ValidatedNewsletter
};
export type SubmitResult =
| { status: 'OK'; submissionId: number }
| { status: 'OK'; submissionId: number; newsletterDropped: boolean }
| { status: 'NOT_FOUND' }
| { status: 'CLOSED' }
| { status: 'EMPTY' };
@@ -160,6 +160,12 @@ export const submitFeedback = async (slug: string, body: SubmissionRequestBody,
const answerRows = validateAnswers(body.answers || [], questionsById, songTitleById);
const guestBook = validateGuestBook(body.guestBook);
const newsletter = validateNewsletter(body.newsletter);
// body.newsletter is only sent at all when the visitor had the opt-in
// checkbox on (see FeedbackForm.tsx), so a present-but-invalid block
// (e.g. a mistyped email) is distinguishable from "didn't opt in" - the
// rest of the submission still saves, but the client can tell the
// visitor their newsletter signup specifically didn't go through.
const newsletterDropped = !!body.newsletter && !newsletter;
if (answerRows.length === 0 && !guestBook && !newsletter) {
return {status: 'EMPTY'};
@@ -213,7 +219,7 @@ export const submitFeedback = async (slug: string, body: SubmissionRequestBody,
});
}
return {status: 'OK', submissionId};
return {status: 'OK', submissionId, newsletterDropped};
} catch (err) {
await conn.rollback();
throw err;
+2 -1
View File
@@ -1,4 +1,5 @@
import {escapeCsvField, formatDatetime} from '../../src/models/feedback/admin/csv.service';
import {escapeCsvField} from '../../src/models/feedback/admin/csv.service';
import {formatDatetime} from '../../src/models/feedback/feedback.dates';
describe('escapeCsvField', () => {
it('passes plain text through unchanged', () => {
+3 -3
View File
@@ -2,7 +2,7 @@ import {aggregateReport} from '../../src/models/feedback/admin/reports.admin.ser
import {AnswerRow} from '../../src/models/feedback/admin/reports.admin.interface';
const eventMeta = {eventId: 1, name: 'Sommerkonzert', eventDate: '2026-08-01', feedbackDeadline: '2026-08-15T23:59:59'};
const emptyNewsletter = {total: 0, sent: 0, pending: 0, failed: 0};
const emptyNewsletter = {total: 0, sent: 0, pending: 0, failed: 0, skipped: 0};
const row = (overrides: Partial<AnswerRow>): AnswerRow => ({
submissionId: 1,
@@ -90,13 +90,13 @@ describe('aggregateReport - top-level fields', () => {
{totalSubmissions: 42, firstSubmissionAt: '2026-08-02T10:00:00.000Z', lastSubmissionAt: '2026-08-10T18:00:00.000Z'},
[],
7,
{total: 10, sent: 6, pending: 2, failed: 2}
{total: 10, sent: 6, pending: 2, failed: 1, skipped: 1}
);
expect(report.totalSubmissions).toBe(42);
expect(report.firstSubmissionAt).toBe('2026-08-02T10:00:00.000Z');
expect(report.lastSubmissionAt).toBe('2026-08-10T18:00:00.000Z');
expect(report.guestBookCount).toBe(7);
expect(report.newsletter).toEqual({total: 10, sent: 6, pending: 2, failed: 2});
expect(report.newsletter).toEqual({total: 10, sent: 6, pending: 2, failed: 1, skipped: 1});
expect(report.event).toEqual(eventMeta);
});
+198
View File
@@ -0,0 +1,198 @@
// The module under test caches its OAuth token at module scope (see
// salesforce.service.ts's `cachedToken`), so every test resets the module
// registry for a clean cache. That also invalidates any jest.mock() factory
// instance captured before the reset, so every mocked dependency (axios,
// Feedback.db, the logger) is re-required fresh after each reset rather
// than referenced from a top-level import.
jest.mock('axios');
jest.mock('../../src/models/feedback/Feedback.db', () => ({
NachklangFeedbackDB: {getConnection: jest.fn()}
}));
jest.mock('../../src/middleware/logger', () => ({
__esModule: true,
default: {info: jest.fn(), error: jest.fn()}
}));
const SIGNUP_ROW = {
signup_id: 7,
first_name: 'Erika',
last_name: 'Mustermann',
email: 'erika@example.com',
event_name: 'Sommerkonzert 2026'
};
const makeConn = (rows: any[]) => ({
query: jest.fn().mockResolvedValue(rows),
end: jest.fn().mockResolvedValue(undefined)
});
// Re-requires every mocked dependency fresh (see the note above) and
// returns the live references plus the service under test.
const freshImports = () => {
const axios = require('axios');
const {NachklangFeedbackDB} = require('../../src/models/feedback/Feedback.db');
const logger = require('../../src/middleware/logger').default;
const {syncNewsletterSignup} = require('../../src/models/feedback/integrations/salesforce.service');
return {axios, mockGetConnection: NachklangFeedbackDB.getConnection as jest.Mock, logger, syncNewsletterSignup};
};
const ORIGINAL_ENV = {...process.env};
describe('syncNewsletterSignup - disabled mode', () => {
beforeEach(() => {
jest.resetModules();
process.env = {...ORIGINAL_ENV, SALESFORCE_ENABLED: 'false'};
});
it('logs the payload it would send and does not touch the network or write to the DB', async () => {
const {axios, mockGetConnection, logger, syncNewsletterSignup} = freshImports();
const conn = makeConn([SIGNUP_ROW]);
mockGetConnection.mockResolvedValue(conn);
await syncNewsletterSignup(7);
expect(logger.info).toHaveBeenCalledWith(
expect.stringContaining('would have sent'),
expect.objectContaining({
signupId: 7,
payload: {firstName: 'Erika', lastName: 'Mustermann', email: 'erika@example.com', eventName: 'Sommerkonzert 2026'}
})
);
expect(axios.post).not.toHaveBeenCalled();
// One read connection only - no UPDATE issued, since the row's
// sync_status is already 'SKIPPED' from the insert.
expect(mockGetConnection).toHaveBeenCalledTimes(1);
});
it('logs and returns without calling the network when the signup row does not exist', async () => {
const {axios, mockGetConnection, logger, syncNewsletterSignup} = freshImports();
mockGetConnection.mockResolvedValue(makeConn([]));
await syncNewsletterSignup(999);
expect(logger.error).toHaveBeenCalledWith(expect.stringContaining('not found'), {signupId: 999});
expect(axios.post).not.toHaveBeenCalled();
});
});
describe('syncNewsletterSignup - enabled mode', () => {
beforeEach(() => {
jest.resetModules();
process.env = {
...ORIGINAL_ENV,
SALESFORCE_ENABLED: 'true',
SALESFORCE_API_URL: 'https://example.my.salesforce.com',
SALESFORCE_CLIENT_ID: 'client-id',
SALESFORCE_CLIENT_SECRET: 'client-secret'
};
});
it('fetches a token, posts the signup, and marks the row SENT with the returned record id', async () => {
const {axios, mockGetConnection, syncNewsletterSignup} = freshImports();
const updateConn = makeConn([]);
mockGetConnection.mockResolvedValueOnce(makeConn([SIGNUP_ROW])).mockResolvedValueOnce(updateConn);
axios.post.mockImplementation((url: string) => {
if (url.endsWith('/services/oauth2/token')) return Promise.resolve({data: {access_token: 'tok-1'}});
if (url.endsWith('/services/apexrest/newsletter/signup')) {
return Promise.resolve({data: {status: 'PENDING_CONFIRMATION', salesforceObject: 'Lead', salesforceRecordId: '00Q1234', created: true}});
}
throw new Error(`unexpected url ${url}`);
});
await syncNewsletterSignup(7);
expect(axios.post).toHaveBeenCalledWith(
'https://example.my.salesforce.com/services/oauth2/token',
expect.any(String),
expect.objectContaining({headers: {'Content-Type': 'application/x-www-form-urlencoded'}})
);
expect(axios.post).toHaveBeenCalledWith(
'https://example.my.salesforce.com/services/apexrest/newsletter/signup',
{firstName: 'Erika', lastName: 'Mustermann', email: 'erika@example.com', eventName: 'Sommerkonzert 2026'},
expect.objectContaining({headers: {Authorization: 'Bearer tok-1'}})
);
expect(updateConn.query).toHaveBeenCalledWith(expect.stringContaining("sync_status = 'SENT'"), ['00Q1234', 7]);
});
it('reuses the cached token across two calls instead of fetching twice', async () => {
const {axios, mockGetConnection, syncNewsletterSignup} = freshImports();
mockGetConnection
.mockResolvedValueOnce(makeConn([SIGNUP_ROW]))
.mockResolvedValueOnce(makeConn([]))
.mockResolvedValueOnce(makeConn([SIGNUP_ROW]))
.mockResolvedValueOnce(makeConn([]));
axios.post.mockImplementation((url: string) => {
if (url.endsWith('/services/oauth2/token')) return Promise.resolve({data: {access_token: 'tok-1'}});
return Promise.resolve({data: {status: 'PENDING_CONFIRMATION', salesforceObject: 'Lead', salesforceRecordId: '00Q1234', created: true}});
});
await syncNewsletterSignup(7);
await syncNewsletterSignup(7);
const tokenCalls = axios.post.mock.calls.filter(([url]: [string]) => url.endsWith('/services/oauth2/token'));
expect(tokenCalls).toHaveLength(1);
});
it('retries once with a fresh token on a 401, then succeeds', async () => {
const {axios, mockGetConnection, syncNewsletterSignup} = freshImports();
const updateConn = makeConn([]);
mockGetConnection.mockResolvedValueOnce(makeConn([SIGNUP_ROW])).mockResolvedValueOnce(updateConn);
let tokenFetches = 0;
axios.post.mockImplementation((url: string) => {
if (url.endsWith('/services/oauth2/token')) {
tokenFetches += 1;
return Promise.resolve({data: {access_token: `tok-${tokenFetches}`}});
}
if (url.endsWith('/services/apexrest/newsletter/signup')) {
if (tokenFetches === 1) {
const err: any = new Error('Unauthorized');
err.response = {status: 401, data: {message: 'Session expired'}};
return Promise.reject(err);
}
return Promise.resolve({data: {status: 'PENDING_CONFIRMATION', salesforceObject: 'Lead', salesforceRecordId: '00Q9999', created: true}});
}
throw new Error(`unexpected url ${url}`);
});
await syncNewsletterSignup(7);
expect(tokenFetches).toBe(2);
expect(updateConn.query).toHaveBeenCalledWith(expect.stringContaining("sync_status = 'SENT'"), ['00Q9999', 7]);
});
it('marks the row FAILED with the error message on a non-401 error, without throwing', async () => {
const {axios, mockGetConnection, logger, syncNewsletterSignup} = freshImports();
const updateConn = makeConn([]);
mockGetConnection.mockResolvedValueOnce(makeConn([SIGNUP_ROW])).mockResolvedValueOnce(updateConn);
axios.post.mockImplementation((url: string) => {
if (url.endsWith('/services/oauth2/token')) return Promise.resolve({data: {access_token: 'tok-1'}});
const err: any = new Error('Internal error');
err.response = {status: 500, data: {message: 'The newsletter signup could not be processed.'}};
return Promise.reject(err);
});
await expect(syncNewsletterSignup(7)).resolves.toBeUndefined();
expect(updateConn.query).toHaveBeenCalledWith(
expect.stringContaining("sync_status = 'FAILED'"),
['The newsletter signup could not be processed.', 7]
);
expect(logger.error).toHaveBeenCalledWith('syncNewsletterSignup failed', expect.objectContaining({signupId: 7}));
});
it('marks the row FAILED with a clear message when client credentials are not configured', async () => {
process.env.SALESFORCE_CLIENT_ID = '';
const {mockGetConnection, syncNewsletterSignup} = freshImports();
const updateConn = makeConn([]);
mockGetConnection.mockResolvedValueOnce(makeConn([SIGNUP_ROW])).mockResolvedValueOnce(updateConn);
await syncNewsletterSignup(7);
expect(updateConn.query).toHaveBeenCalledWith(
expect.stringContaining("sync_status = 'FAILED'"),
[expect.stringContaining('SALESFORCE_CLIENT_ID'), 7]
);
});
});