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