import {MAX_ANSWER_ROWS, validateAnswers, validateGuestBook, validateNewsletter} from '../../src/models/feedback/public/submissions.service'; type QuestionLookup = Map; const songTitleById = new Map([ [1, 'Abendlied'], [2, 'Morgenlied'] ]); describe('validateAnswers', () => { const questionsById: QuestionLookup = new Map([ [10, {eventQuestionId: 10, questionId: 100, type: 'SONG_PICK', label: 'Lieblingsstück?', position: 0}], [11, {eventQuestionId: 11, questionId: 101, type: 'SONG_RATING', label: 'Bewertung', position: 1}], [12, {eventQuestionId: 12, questionId: 102, type: 'FREE_TEXT', label: 'Sonstiges', position: 2}] ]); it('produces a row for a valid SONG_PICK answer', () => { const rows = validateAnswers([{eventQuestionId: 10, songId: 1}], questionsById, songTitleById); expect(rows).toEqual([ {eventQuestionId: 10, questionId: 100, label: 'Lieblingsstück?', type: 'SONG_PICK', position: 0, songId: 1, songTitle: 'Abendlied', rating: null, text: null} ]); }); it('ignores a SONG_PICK answer with an unknown songId', () => { const rows = validateAnswers([{eventQuestionId: 10, songId: 999}], questionsById, songTitleById); expect(rows).toHaveLength(0); }); it('ignores an answer for an unknown eventQuestionId', () => { const rows = validateAnswers([{eventQuestionId: 999, songId: 1}], questionsById, songTitleById); expect(rows).toHaveLength(0); }); it('produces one row per rated song for SONG_RATING, ignoring unknown songs', () => { const rows = validateAnswers([ {eventQuestionId: 11, ratings: [{songId: 1, rating: 5}, {songId: 2, rating: 3}, {songId: 999, rating: 4}]} ], questionsById, songTitleById); expect(rows).toHaveLength(2); expect(rows.map(r => r.songId)).toEqual([1, 2]); }); it('clamps ratings to the 1..5 range', () => { const rows = validateAnswers([ {eventQuestionId: 11, ratings: [{songId: 1, rating: 9}, {songId: 2, rating: -3}]} ], questionsById, songTitleById); expect(rows.find(r => r.songId === 1)?.rating).toBe(5); expect(rows.find(r => r.songId === 2)?.rating).toBe(1); }); it('an unrated song in a SONG_RATING block produces no row', () => { const rows = validateAnswers([{eventQuestionId: 11, ratings: []}], questionsById, songTitleById); expect(rows).toHaveLength(0); }); it('trims FREE_TEXT and drops it if empty after trimming', () => { const withText = validateAnswers([{eventQuestionId: 12, text: ' Danke für den Abend! '}], questionsById, songTitleById); expect(withText[0].text).toBe('Danke für den Abend!'); const blank = validateAnswers([{eventQuestionId: 12, text: ' '}], questionsById, songTitleById); expect(blank).toHaveLength(0); }); it('caps FREE_TEXT at 5000 characters', () => { const long = 'a'.repeat(6000); const rows = validateAnswers([{eventQuestionId: 12, text: long}], questionsById, songTitleById); expect(rows[0].text).toHaveLength(5000); }); it('a fully empty answer set produces no rows (skipped questions produce no rows)', () => { const rows = validateAnswers([], questionsById, songTitleById); expect(rows).toHaveLength(0); }); it('de-duplicates repeated ratings for the same song, keeping the last value', () => { const rows = validateAnswers([ {eventQuestionId: 11, ratings: [{songId: 1, rating: 2}, {songId: 1, rating: 5}, {songId: 1, rating: 3}]} ], questionsById, songTitleById); expect(rows).toHaveLength(1); expect(rows[0].rating).toBe(3); }); it('caps total generated rows at MAX_ANSWER_ROWS regardless of how many ratings are submitted', () => { const massRatings = Array.from({length: MAX_ANSWER_ROWS + 500}, (_, i) => ({ songId: 1, rating: (i % 5) + 1 })); // Force distinct songIds so de-duplication alone can't be the thing capping the count. const distinctSongTitleById = new Map( Array.from({length: MAX_ANSWER_ROWS + 500}, (_, i) => [i, `Song ${i}`]) ); const distinctRatings = massRatings.map((r, i) => ({songId: i, rating: r.rating})); const rows = validateAnswers( [{eventQuestionId: 11, ratings: distinctRatings}], questionsById, distinctSongTitleById ); expect(rows.length).toBe(MAX_ANSWER_ROWS); }); it('stops adding rows across multiple answers once the cap is reached', () => { const distinctSongTitleById = new Map( Array.from({length: MAX_ANSWER_ROWS + 10}, (_, i) => [i, `Song ${i}`]) ); const answers = Array.from({length: MAX_ANSWER_ROWS + 10}, (_, i) => ({ eventQuestionId: 10, songId: i })); // SONG_PICK only ever produces 0 or 1 row per answer entry, so this // exercises the cap across many separate answers, not one big array. const rows = validateAnswers(answers, questionsById, distinctSongTitleById); expect(rows.length).toBe(MAX_ANSWER_ROWS); }); }); describe('validateGuestBook', () => { it('returns null when nothing was filled in', () => { expect(validateGuestBook(undefined)).toBeNull(); expect(validateGuestBook({displayName: ' ', message: ' '})).toBeNull(); }); it('keeps a valid entry with only a display name', () => { expect(validateGuestBook({displayName: 'Familie Müller'})).toEqual({displayName: 'Familie Müller', message: null}); }); it('caps the message at 2000 characters', () => { const long = 'x'.repeat(3000); const result = validateGuestBook({message: long}); expect(result?.message).toHaveLength(2000); }); }); describe('validateNewsletter', () => { it('returns null when the object is missing', () => { expect(validateNewsletter(undefined)).toBeNull(); }); it('drops the signup silently when the email is invalid', () => { expect(validateNewsletter({firstName: 'Anna', lastName: 'Beispiel', email: 'not-an-email'})).toBeNull(); }); it('drops the signup when first or last name is missing', () => { expect(validateNewsletter({firstName: '', lastName: 'Beispiel', email: 'a@b.de'})).toBeNull(); expect(validateNewsletter({firstName: 'Anna', lastName: '', email: 'a@b.de'})).toBeNull(); }); it('accepts a fully valid signup', () => { expect(validateNewsletter({firstName: 'Anna', lastName: 'Beispiel', email: 'anna@beispiel.de'})).toEqual({ firstName: 'Anna', lastName: 'Beispiel', email: 'anna@beispiel.de' }); }); });