apps/web/src/lib/userProfile.test.ts

1import { expect, test } from 'bun:test'
2import { HANDOFF_CONSENT } from '@local/config/ai-consent'
3import {
4	formatProfileTable,
5	parseUserProfile,
6	retainTicketBooking,
7	ticketEnquiryReply,
8} from './userProfile'
9
10test('estimated ticket ranges remain exact enquiry facts, survive contact replies and clear on correction or event change', () => {
11	const range = retainTicketBooking(
12		{},
13		{},
14		'We are bringing around 5-6 people from our team to the event.',
15		'/events/unleash-paris',
16		'How many tickets would you like?',
17	)
18	expect(range).toMatchObject({
19		ticketQuantityRange: '5–6',
20		ticketEvent: 'paris',
21	})
22	expect(range.ticketQuantity).toBeUndefined()
23	expect(
24		retainTicketBooking(
25			{ ticketQuantity: 5 },
26			range,
27			'My name is Jordan.',
28			'/events/unleash-paris',
29		).ticketQuantityRange,
30	).toBe('5–6')
31	expect(
32		retainTicketBooking(
33			{},
34			range,
35			'Actually, 6 tickets please.',
36			'/events/unleash-paris',
37		),
38	).toMatchObject({ ticketQuantity: 6, ticketQuantityRange: undefined })
39	expect(
40		retainTicketBooking(
41			{},
42			range,
43			'We need Miami tickets',
44			'/events/unleash-miami',
45		).ticketQuantityRange,
46	).toBeUndefined()
47	expect(
48		retainTicketBooking(
49			{},
50			{},
51			'We employ 500-600 people',
52			'/events/unleash-paris',
53		).ticketQuantityRange,
54	).toBeUndefined()
55	expect(parseUserProfile({ ticketQuantityRange: '5–6' })).toMatchObject({
56		ticketQuantityRange: '5–6',
57	})
58	expect(parseUserProfile({ ticketQuantityRange: '6–5' })).toBeUndefined()
59	const summary = ticketEnquiryReply(
60		{
61			...range,
62			company: 'Meridian HR',
63			role: 'HR Director',
64			name: 'Jordan',
65			email: 'jordan@example.com',
66			ticketPass: 'General Attendee',
67		},
68		'paris',
69	)
70	expect(summary).toContain('5–6 (estimated)')
71	expect(summary).not.toContain('How many tickets')
72})
73
74test('profile persists canonical pillar IDs separately and migrates explicit legacy labels', () => {
75	expect(
76		parseUserProfile({
77			decisionPillars: [
78				'aiautomationgovernance',
79				'invalid',
80				'aiautomationgovernance',
81			],
82			interests: ['leadership'],
83		}),
84	).toMatchObject({
85		decisionPillars: ['aiautomationgovernance'],
86		interests: ['leadership'],
87	})
88	expect(
89		parseUserProfile({ interests: ['Data, Trust, People & Infrastructure'] })
90			?.decisionPillars,
91	).toEqual(['datatrustpeopleinfrastructure'])
92	expect(parseUserProfile({ interests: ['AI'] })?.decisionPillars).toEqual([])
93})
94
95test('ticket enquiry rejects a personal address before consent', () => {
96	expect(
97		ticketEnquiryReply(
98			{ ...ticketProfile, email: 'visitor@gmail.com' },
99			'paris',
100			undefined,
101		),
102	).toBe('Please use your work email address.')
103})
104
105const ticketProfile = {
106	name: 'Test Visitor',
107	email: 'visitor@example.com',
108	company: 'Example Manufacturing',
109	role: 'HR Director',
110	ticketQuantity: 2,
111	ticketPass: 'General Attendee',
112	ticketEvent: 'paris' as const,
113}
114
115test('complete ticket details go directly to a single consent summary', () => {
116	const reply = ticketEnquiryReply(ticketProfile, 'paris', undefined)
117	expect(reply).toContain('Quantity: 2')
118	expect(reply).toContain('Requested pass: General Attendee')
119	expect(reply).toContain('Company: Example Manufacturing')
120	expect(reply.match(/\?/g)?.length).toBe(1)
121	expect(reply).toEndWith(HANDOFF_CONSENT.tickets)
122})
123
124test('only missing ticket fields are requested and other-event details are not reused', () => {
125	expect(
126		ticketEnquiryReply({ ...ticketProfile, role: undefined }, 'paris', ''),
127	).toBe('What is your role?')
128	expect(
129		ticketEnquiryReply(
130			{ ...ticketProfile, ticketQuantity: undefined },
131			'paris',
132			'',
133		),
134	).toContain('How many tickets')
135	expect(ticketEnquiryReply(ticketProfile, 'miami', '')).toContain(
136		'How many tickets',
137	)
138	expect(
139		ticketEnquiryReply({ ...ticketProfile, ticketQuantity: 3 }, 'paris', ''),
140	).toContain('Quantity: 3')
141})
142
143test('booking profile input is bounded and included in the agent table', () => {
144	expect(
145		parseUserProfile({ ...ticketProfile, ticketQuantity: -2 })?.ticketQuantity,
146	).toBeUndefined()
147	expect(formatProfileTable(parseUserProfile(ticketProfile))).toContain(
148		'| requested pass | General Attendee |',
149	)
150})
151
152test('transactional requests are not saved learning interests', () => {
153	expect(
154		parseUserProfile({
155			interests: [
156				'tickets',
157				'General Attendee badges',
158				'AI',
159				'skills',
160				'email agenda',
161			],
162		})?.interests,
163	).toEqual(['AI', 'skills'])
164})
165
166test('known and missing facts are explicit in the agent table', () => {
167	const table = formatProfileTable({
168		company: 'Example Manufacturing',
169		role: 'HR Director',
170	})
171	expect(table).toContain('| company | Example Manufacturing |')
172	expect(table).toContain('| role | HR Director |')
173	expect(table).toContain('| email | Not provided |')
174	expect(table).toContain('Do not ask again')
175})
176
177test('profile values cannot add table rows and unsupported facts are discarded', () => {
178	const profile = parseUserProfile({
179		company: 'Example | Ltd\nNew row',
180		consent: true,
181	})
182	expect(formatProfileTable(profile)).toContain(
183		'| company | Example \\| Ltd New row |',
184	)
185	expect(profile).not.toHaveProperty('consent')
186})
187
188test('booking details survive null extraction, with explicit event and new-booking resets', () => {
189	expect(
190		retainTicketBooking(
191			{},
192			ticketProfile,
193			'My name is Jordan',
194			'/events/unleash-paris',
195		),
196	).toMatchObject({
197		ticketQuantity: 2,
198		ticketPass: 'General Attendee',
199		ticketEvent: 'paris',
200	})
201	expect(
202		retainTicketBooking(
203			{},
204			{},
205			"Could you help me with the registration? I'd like to make sure I'm all set with a ticket first",
206			'/events/unleash-paris',
207		),
208	).toMatchObject({ ticketQuantity: 1, ticketEvent: 'paris' })
209	expect(
210		retainTicketBooking(
211			{},
212			ticketProfile,
213			'I want to book for Miami',
214			'/events/unleash-paris',
215		).ticketQuantity,
216	).toBeUndefined()
217	expect(
218		retainTicketBooking(
219			{},
220			ticketProfile,
221			'Start another booking',
222			'/events/unleash-paris',
223		).ticketPass,
224	).toBeUndefined()
225	expect(
226		retainTicketBooking({}, {}, 'I do not want a ticket', '/events/unleash-paris')
227			.ticketQuantity,
228	).toBeUndefined()
229	expect(
230		retainTicketBooking(
231			{ ticketQuantity: 3 },
232			ticketProfile,
233			'Actually three please',
234			'/events/unleash-paris',
235		).ticketQuantity,
236	).toBe(3)
237})
238
239test('singular badge information questions do not supply booking quantities', () => {
240	expect(
241		retainTicketBooking(
242			{},
243			{},
244			'I want to know what a Diamond badge includes',
245			'/events/unleash-paris',
246		).ticketQuantity,
247	).toBeUndefined()
248})
249
250test('a requested group quote is retained as an unspecified pass, not a fabricated badge choice', () => {
251	const profile = retainTicketBooking(
252		{ company: 'Example', role: 'HR Director', ticketQuantity: 4 },
253		{},
254		'Can your team follow up with a custom quote for 4 attendees?',
255		'/events/unleash-miami',
256	)
257	expect(profile.ticketPass).toBe(
258		'Pass options / group quote (no pass selected)',
259	)
260	expect(ticketEnquiryReply(profile, 'miami')).toContain('What name')
261	const complete = { ...profile, name: 'Jordan', email: 'jordan@example.com' }
262	const reply = ticketEnquiryReply(complete, 'miami')
263	expect(reply).toContain('no pass selected')
264	expect(reply).toContain('Quantity: 4')
265	expect(reply).not.toContain('Which pass')
266	expect(reply).toContain('not a confirmed booking')
267})
268