${e.recommendationReason}
` : ''}${e.title}
${e.area ? html` · ${e.area}` : ''}
- ${e.inclusions.length ? html`${e.inclusions.slice(0, 3).join(' · ')}
` : ''} + ${e.inclusions && e.inclusions.length ? html`${e.inclusions.slice(0, 3).join(' · ')}
` : ''}Running an event in South India? Submit it for review — every listing is checked by a person before it appears.
`; export function renderAllEvents({ events, filter = {} }) { - const isFiltered = Boolean(filter.city || filter.category || filter.when || filter.q); + const isFiltered = Boolean(filter.city || filter.category || filter.when || filter.price || filter.q); const emptyMsg = filter.q ? `No upcoming events match "${filter.q}". Try searching for dinner, party, meetup, or trip.` : isFiltered diff --git a/lib/views/event.js b/lib/views/event.js index d44c61c..8505f5e 100644 --- a/lib/views/event.js +++ b/lib/views/event.js @@ -183,7 +183,13 @@ export function renderEvent(e, { preview = false, previewBanner = '' } = {}) {${e.isTrip ? 'Trips' : 'Upcoming'} / ${e.city}
${e.title}
+${e.title}
+ +${e.summary}
` : ''} ${e.isExternal ? html`Listed from ${e.sourceUrl ? html`${e.sourceName}` : e.sourceName}. Details may change — the organiser’s page is the source of truth.
` : ''} ${e.isDemo ? html`This is a sample listing used to preview the site. It is not a real event and cannot be booked for real.
` : ''} diff --git a/lib/views/pages.js b/lib/views/pages.js index e804382..08b6854 100644 --- a/lib/views/pages.js +++ b/lib/views/pages.js @@ -445,14 +445,36 @@ export function renderSignin({ next = '/me', error = '', oidc = false, devLogin } export function renderMe() { - const body = html`${pageHeader('Members', 'My bookings', '')} -Loading…
Loading bookings…
Loading saved events…
Loading host announcements…
No bookings yet
' + + 'When you reserve a spot for a meetup, dinner, party or trip, your digital passes and secret venue locations will be saved here.
' + + '' + + 'No ' + esc(currentBookingFilter) + ' bookings
You do not have any ' + esc(currentBookingFilter) + ' bookings.
- ' + filtered.map(function (b) {
+ var shortRef = b.id ? b.id.slice(0, 8).toUpperCase() : '';
+ var checkinNotice = b.isCheckedIn ? '✓ Checked In' : '';
+ return '
- ' +
+ '' + + '' + + '' + esc(STATUS[b.status] || b.status) + '' + checkinNotice + '' + + '#SM-' + esc(shortRef) + '' + + '
' + esc(b.title) + '
' + + '' + esc(fmt(b.startAt)) + ' · ' + esc(b.city) + '
' + + '' + + 'View Pass & Venue →' + + (b.slug ? 'Event Details' : '') + + '' + + ' ';
+ }).join('') + '
No bookings yet
' + - 'When you reserve a spot for a meetup, dinner, party or trip, your digital passes and secret venue locations will be saved here.
' + + el.innerHTML = 'No saved events yet
' + + 'Tap the bookmark icon on any event to save it here for quick access later.
' + '' + '- ' + list.map(function (b) {
- var shortRef = b.id ? b.id.slice(0, 8).toUpperCase() : '';
- return '
- ' +
- '' + - '' + esc(STATUS[b.status] || b.status) + '' + - '#SM-' + esc(shortRef) + '' + + el.innerHTML = '
- ' + list.map(function (e) {
+ var href = (e.isTrip ? '/trips/' : '/events/') + e.slug;
+ return '
' + + ' ' + + '' + + '' + esc(e.title) + '
' + + '' + esc(fmt(e.startAt)) + (e.area ? ' · ' + esc(e.area) : '') + '
' + + '' + + '' + esc(e.priceLabel || 'Free') + '' + + '' + - '' + + 'View Details' + + '' + + '' + '' + esc(b.title) + '
' + - '' + esc(fmt(b.startAt)) + ' · ' + esc(b.city) + '
' + - '' + - 'View Pass & Venue →' + - (b.slug ? 'Event Details' : '') + '' + - ''; + ''; }).join('') + ''; }); } + function renderMyAnnouncements(el) { + api('GET', '/api/me/announcements').then(function (r) { + if (r.status === 401) return; + var list = r.announcements || []; + if (!list.length) { + el.innerHTML = '' + + '' + + ''; + return; + } + el.innerHTML = 'No host announcements yet
' + + 'Operational updates, venue reminders, and door announcements from hosts of your events will appear here.
' + + '' + list.map(function (a) { + return ''; + }); + } + + function initAttendeeHub() { + var hub = $('[data-attendee-hub]'); + if (!hub) return; + var tabBtns = $$('[data-hub-tab]', hub); + tabBtns.forEach(function (btn) { + btn.addEventListener('click', function () { + var tab = btn.getAttribute('data-hub-tab'); + tabBtns.forEach(function (b) { + b.classList.toggle('active', b === btn); + b.setAttribute('aria-selected', b === btn ? 'true' : 'false'); + }); + $$('[data-hub-section]', hub).forEach(function (sec) { + var isTarget = sec.getAttribute('data-hub-section') === tab; + sec.hidden = !isTarget; + }); + }); + }); + + var filterBtns = $$('[data-booking-filter]', hub); + filterBtns.forEach(function (btn) { + btn.addEventListener('click', function () { + var filter = btn.getAttribute('data-booking-filter'); + filterBtns.forEach(function (b) { + b.classList.toggle('active', b === btn); + }); + var mb = $('[data-my-bookings]', hub); + if (mb) filterAndDisplayBookings(mb, filter); + }); + }); + } + + function initSaveEventHandlers() { + document.addEventListener('click', function (ev) { + var saveBtn = ev.target && ev.target.closest ? ev.target.closest('[data-save-event]') : null; + if (saveBtn) { + ev.preventDefault(); + var eventId = saveBtn.getAttribute('data-save-event'); + var isSaved = saveBtn.classList.contains('is-saved'); + if (!state.me) { + return goSignin(); + } + var nextState = !isSaved; + var updateBtn = function (btn, saved) { + btn.classList.toggle('is-saved', saved); + btn.setAttribute('aria-pressed', saved ? 'true' : 'false'); + btn.setAttribute('aria-label', saved ? 'Remove from saved events' : 'Save event'); + var svg = btn.querySelector('svg'); + if (svg) svg.setAttribute('fill', saved ? 'currentColor' : 'none'); + var span = btn.querySelector('span'); + if (span) span.textContent = saved ? 'Saved' : 'Save'; + }; + + updateBtn(saveBtn, nextState); + var method = nextState ? 'POST' : 'DELETE'; + api(method, '/api/events/' + encodeURIComponent(eventId) + '/save').then(function (r) { + if (!r.ok) { + updateBtn(saveBtn, isSaved); + } + }).catch(function () { + updateBtn(saveBtn, isSaved); + }); + return; + } + + var unsaveBtn = ev.target && ev.target.closest ? ev.target.closest('[data-unsave-event]') : null; + if (unsaveBtn) { + ev.preventDefault(); + var eid = unsaveBtn.getAttribute('data-unsave-event'); + api('DELETE', '/api/events/' + encodeURIComponent(eid) + '/save').then(function (r) { + if (r.ok) { + var card = unsaveBtn.closest('.card'); + if (card) card.remove(); + var se = $('[data-saved-events]'); + if (se && !se.querySelectorAll('.card').length) { + renderSavedEvents(se); + } + } + }); + return; + } + }); + } + function renderStatus(el) { var id = el.getAttribute('data-booking-status'); var isExistingRedirect = new URLSearchParams(location.search).get('existing') === '1'; @@ -475,6 +639,12 @@ } else if (b.status === 'pending_payment') { html += '' + + ''; + }).join('') + '' + + '' + esc(a.eventTitle) + '' + + '' + esc(fmt(a.createdAt)) + '' + + '' + + '' + (a.isPinned ? '📌 ' : '') + esc(a.title) + '
' + + '' + esc(a.message) + '
' + + '' + + 'We are waiting for SuperProfile to confirm your payment. If you already paid, send /claim to our Telegram bot with your SuperProfile order ID.
'; } else if (b.status === 'confirmed' || b.status === 'checked_in') { + if (b.isCheckedIn || b.status === 'checked_in') { + html += ''; + } if (b.tickets && b.tickets.length > 0) { html += '' + b.tickets.map(function (tk, idx) { @@ -493,6 +663,17 @@ if (b.venue) { html += ''; } + if (b.announcements && b.announcements.length > 0) { + html += '' + + ''; + } html += '📢 Updates from your host
' + + b.announcements.map(function (a) { + return '' + + '' + (a.isPinned ? '📌 ' : '') + esc(a.title) + ' · ' + esc(fmt(a.createdAt)) + '' + + ''; + }).join('') + + '' + esc(a.message) + '
' + + 'You are in. ' + (b.ticket ? 'Your booking pass is confirmed.' : 'Your confirmation and private group invite are sent by our Telegram bot.') + '
'; if (b.invite) html += 'Single-use invite, valid until ' + esc(fmt(b.invite.expiresAt)) + '.
'; if (BOT) html += ''; @@ -1063,7 +1244,7 @@ if (!form.getAttribute('data-async-bound')) { form.setAttribute('data-async-bound', '1'); form.addEventListener('change', function (ev) { - if (ev.target && (ev.target.name === 'city' || ev.target.name === 'category' || ev.target.name === 'when')) { + if (ev.target && (ev.target.name === 'city' || ev.target.name === 'category' || ev.target.name === 'when' || ev.target.name === 'price' || ev.target.name === 'sort')) { onFilterChange(); } }); @@ -1127,8 +1308,12 @@ initInstantDiscovery(); miniApp().then(refreshMe).then(function () { + initAttendeeHub(); + initSaveEventHandlers(); var pf = $('[data-profile]'); if (pf && state.me) renderProfile(pf); var mb = $('[data-my-bookings]'); if (mb) renderBookings(mb); + var se = $('[data-saved-events]'); if (se && state.me) renderSavedEvents(se); + var ma = $('[data-my-announcements]'); if (ma && state.me) renderMyAnnouncements(ma); var bs = $('[data-booking-status]'); if (bs) renderStatus(bs); var sr = $('[data-safety-report]'); if (sr) renderSafetyReport(sr); var ss = $('[data-safety-settings]'); if (ss && state.me) renderSafetySettings(ss); diff --git a/scripts/seed-local.js b/scripts/seed-local.js index 705005e..803a505 100644 --- a/scripts/seed-local.js +++ b/scripts/seed-local.js @@ -109,6 +109,7 @@ async function clearPersonaData(c) { await c.query(`DELETE FROM entitlements WHERE user_id IN (SELECT id FROM _pu)`); await c.query(`DELETE FROM external_orders WHERE event_id IN (SELECT id FROM _pe) OR matched_booking_id IN (SELECT id FROM _pb)`); await c.query(`DELETE FROM checkout_intents WHERE event_id IN (SELECT id FROM _pe) OR user_id IN (SELECT id FROM _pu)`); + await c.query(`DELETE FROM saved_events WHERE user_id IN (SELECT id FROM _pu) OR event_id IN (SELECT id FROM _pe)`); await c.query(`DELETE FROM ticket_scans WHERE event_id IN (SELECT id FROM _pe) OR booking_id IN (SELECT id FROM _pb) OR scanned_by IN (SELECT id FROM _pu)`); await c.query(`UPDATE tickets SET reissued_to_ticket_id = NULL WHERE event_id IN (SELECT id FROM _pe) OR booking_id IN (SELECT id FROM _pb) OR user_id IN (SELECT id FROM _pu)`); await c.query(`DELETE FROM tickets WHERE event_id IN (SELECT id FROM _pe) OR booking_id IN (SELECT id FROM _pb) OR user_id IN (SELECT id FROM _pu)`); diff --git a/test/m7-attendee-experience.test.js b/test/m7-attendee-experience.test.js new file mode 100644 index 0000000..ed64103 --- /dev/null +++ b/test/m7-attendee-experience.test.js @@ -0,0 +1,429 @@ +// test/m7-attendee-experience.test.js - Milestone 7: Attendee Discovery Feed, +// Personalised Recommendations, Location & Privacy, Saved Events Lifecycle, +// Multi-facet Filtering, Experience Hub, and QR Pass Integration. +import { test, before, after, beforeEach } from 'node:test'; +import assert from 'node:assert/strict'; +import { resetDb, startServer, stopServer, db, Client, signedInClient, clearCalls } from './helpers.js'; +import { ensurePersonalOrg } from '../lib/services/permissions.js'; +import { getDiscoveryFeed, scoreEventRecommendation, getUserRecommendationContext, parseDateFilter } from '../lib/services/discovery-feed.js'; +import { saveEvent, unsaveEvent, isEventSaved, listSavedEvents, listSavedEventIds } from '../lib/services/saved-events.js'; +import { seedDemo } from '../scripts/seed-demo.js'; + +let organiser; +let attendeeAlice; +let attendeeBob; +let demoEventId; +let sampleSlug; + +before(async () => { + await resetDb(); + await startServer(); + await seedDemo({ e: { DATABASE_URL: process.env.DATABASE_URL || 'postgres://localhost/sm_test' } }); + + organiser = await signedInClient({ tgId: 910000001 }); + await db.query('UPDATE users SET organiser_enabled = true WHERE id = $1', [organiser.userId]); + + attendeeAlice = await signedInClient({ tgId: 920000001 }); + attendeeBob = await signedInClient({ tgId: 920000002 }); + + // Get a seeded demo event ID + const evRow = await db.one(`SELECT id, slug FROM events WHERE slug LIKE 'sample-bengaluru-%' LIMIT 1`); + demoEventId = evRow.id; + sampleSlug = evRow.slug; + const orgId = await ensurePersonalOrg({ id: organiser.userId, alias: 'Organiser' }); + await db.query('UPDATE events SET organiser_user_id = $1, organiser_org_id = $2 WHERE id = $3', [organiser.userId, orgId, demoEventId]); +}); + +after(stopServer); +beforeEach(() => { + clearCalls(); +}); + +test('parseDateFilter: correctly computes IST boundaries for today, tomorrow, weekend, week, month', () => { + const today = parseDateFilter('today'); + assert.ok(today.from && today.to); + assert.ok(new Date(today.from) <= new Date(today.to)); + + const tmrw = parseDateFilter('tomorrow'); + assert.ok(tmrw.from && tmrw.to); + assert.ok(new Date(tmrw.from) > new Date(today.from)); + + const wknd = parseDateFilter('weekend'); + assert.ok(wknd.from && wknd.to); + + const week = parseDateFilter('week'); + assert.ok(week.from && week.to); + + const month = parseDateFilter('month'); + assert.ok(month.from && month.to); + assert.ok(new Date(month.to) > new Date(week.to)); + + const empty = parseDateFilter(''); + assert.deepEqual(empty, {}); +}); + +test('scoreEventRecommendation: transparent and explainable scoring calculation', () => { + const dummyEvent = { + id: '00000000-0000-0000-0000-000000000001', + city: 'Bengaluru', + category: 'nightlife', + start_at: new Date(Date.now() + 24 * 3600e3).toISOString(), + seats_taken: 10, + capacity: 20 + }; + + // Case 1: Anonymous attendee (no user context), matches city + const anonScore = scoreEventRecommendation(dummyEvent, null, 'Bengaluru'); + assert.ok(anonScore.score > 0); + assert.match(anonScore.reason, /Bengaluru|Popular/); + + // Case 2: User with city preference match (+40) + interest match (+30) + trending (+20) + soon (+15) + const userCtx = { + preferredCity: 'Bengaluru', + profileInterests: ['nightlife'], + attendedCategories: [], + savedCategories: [], + savedEventIds: new Set() + }; + const userScore = scoreEventRecommendation(dummyEvent, userCtx); + assert.ok(userScore.score >= 85); + assert.match(userScore.reason, /Bengaluru|Nightlife|Popular/); + + // Case 3: User with attended category match (+25) + const attendedCtx = { + preferredCity: 'Goa', + profileInterests: [], + attendedCategories: ['nightlife'], + savedCategories: [], + savedEventIds: new Set() + }; + const attendedScore = scoreEventRecommendation(dummyEvent, attendedCtx); + assert.ok(attendedScore.score >= 50); + assert.match(attendedScore.reason, /events you joined|Popular/); + + // Case 4: User with saved category match (+15) + const savedCtx = { + preferredCity: 'Goa', + profileInterests: [], + attendedCategories: [], + savedCategories: ['nightlife'], + savedEventIds: new Set() + }; + const savedScore = scoreEventRecommendation(dummyEvent, savedCtx); + assert.ok(savedScore.score >= 35); + assert.match(savedScore.reason, /saved events|Popular/); +}); + +test('getUserRecommendationContext: builds user profile, attended, and saved context correctly', async () => { + // Update Alice profile + await db.query(`UPDATE users SET city = 'Bengaluru', interests = ARRAY['nightlife', 'meetup']::text[] WHERE id = $1`, [attendeeAlice.userId]); + + // Save demo event for Alice + await saveEvent(attendeeAlice.userId, demoEventId); + + const ctx = await getUserRecommendationContext(attendeeAlice.userId); + assert.equal(ctx.preferredCity, 'Bengaluru'); + assert.ok(ctx.profileInterests.includes('nightlife')); + assert.ok(ctx.profileInterests.includes('meetup')); + assert.ok(ctx.savedEventIds.has(demoEventId)); + assert.ok(ctx.savedCategories.length > 0); + + // Cleanup saved + await unsaveEvent(attendeeAlice.userId, demoEventId); +}); + +test('GET /api/discovery/feed: returns structured discovery feed with modules and filters', async () => { + const c = new Client(); + const res = await c.get('/api/discovery/feed?city=bengaluru'); + assert.equal(res.status, 200); + assert.equal(res.json.ok, true); + assert.ok(Array.isArray(res.json.events)); + assert.ok(Array.isArray(res.json.collections)); + assert.ok(Array.isArray(res.json.featured)); + assert.ok(Array.isArray(res.json.trending)); + assert.ok(Array.isArray(res.json.recommended)); + assert.equal(res.json.filter.city, 'bengaluru'); + + // Verify collections + const collections = res.json.collections; + assert.ok(collections.some((col) => col.key === 'nightlife')); + assert.ok(collections.some((col) => col.key === 'dinners')); + assert.ok(collections.some((col) => col.key === 'social')); + + // Verify private venues are not exposed in public feed + for (const ev of res.json.events) { + assert.ok(!ev.venue || ev.venuePrivacy === 'public', 'private venue must never be on public feed event'); + assert.doesNotMatch(JSON.stringify(ev), /Secret Terrace|12th Main Road/, 'secret address never in feed'); + } +}); + +test('GET /api/discovery/feed: category, price, and date filtering', async () => { + const c = new Client(); + + // 1. Category filter + const catRes = await c.get('/api/discovery/feed?category=nightlife'); + assert.equal(catRes.status, 200); + for (const ev of catRes.json.events) { + assert.equal(ev.category, 'nightlife'); + } + + // 2. Price filter: free + const freeRes = await c.get('/api/discovery/feed?price=free'); + assert.equal(freeRes.status, 200); + for (const ev of freeRes.json.events) { + if (ev.ticketing === 'tiers' && ev.tiers?.length) { + assert.ok(ev.tiers.some((t) => t.kind === 'free')); + } else { + assert.equal(ev.pricePaise, 0); + } + } + + // 3. Date filter: month + const monthRes = await c.get('/api/discovery/feed?when=month'); + assert.equal(monthRes.status, 200); + assert.ok(monthRes.json.events.length >= 0); +}); + +test('Location & Privacy: device coordinates provide coarse distance without trilaterating private venue', async () => { + const c = new Client(); + // Bengaluru coordinates (MG Road area) + const res = await c.get('/api/discovery/feed?lat=12.9716&lng=77.5946&radius_km=30'); + assert.equal(res.status, 200); + assert.equal(res.json.ok, true); + assert.equal(res.json.filter.lat, 12.9716); + assert.equal(res.json.filter.lng, 77.5946); + + for (const ev of res.json.events) { + // If distance band is present, verify coarse format + if (ev.distanceBand) { + assert.match(ev.distanceBand, /km/, 'coarse distance band formatted as km'); + } + // Private venue address never leaked + assert.ok(!ev.venue || ev.venuePrivacy === 'public'); + } +}); + +test('Saved Events Lifecycle: save, duplicate idempotency, list, unsave', async () => { + // 1. Unauthenticated save fails with 401 + const anon = new Client(); + const anonSave = await anon.post(`/api/events/${demoEventId}/save`, {}); + assert.equal(anonSave.status, 401); + + // 2. Attendee Alice saves demo event + const save1 = await attendeeAlice.post(`/api/events/${demoEventId}/save`, {}); + assert.equal(save1.status, 200); + assert.equal(save1.json.ok, true); + assert.equal(save1.json.saved, true); + + // 3. Duplicate save is idempotent (does not fail or duplicate) + const save2 = await attendeeAlice.post(`/api/events/${demoEventId}/save`, {}); + assert.equal(save2.status, 200); + assert.equal(save2.json.ok, true); + assert.equal(save2.json.saved, true); + + // 4. Check saved IDs + const idsRes = await attendeeAlice.get('/api/me/saved-event-ids'); + assert.equal(idsRes.status, 200); + assert.ok(idsRes.json.savedIds.includes(demoEventId)); + + // 5. Check saved events list + const listRes = await attendeeAlice.get('/api/me/saved-events'); + assert.equal(listRes.status, 200); + assert.ok(listRes.json.events.length > 0); + const found = listRes.json.events.find((e) => e.id === demoEventId); + assert.ok(found, 'saved event present in list'); + assert.equal(found.isSaved, true); + + // 6. Discovery feed marks isSaved: true for Alice + const feedRes = await attendeeAlice.get('/api/discovery/feed'); + assert.equal(feedRes.status, 200); + const feedEvent = feedRes.json.events.find((e) => e.id === demoEventId); + if (feedEvent) { + assert.equal(feedEvent.isSaved, true); + } + + // 7. Unsave event + const unsaveRes = await attendeeAlice.req('DELETE', `/api/events/${demoEventId}/save`, {}); + assert.equal(unsaveRes.status, 200); + assert.equal(unsaveRes.json.saved, false); + + // 8. Idempotent unsave + const unsave2 = await attendeeAlice.req('DELETE', `/api/events/${demoEventId}/save`, {}); + assert.equal(unsave2.status, 200); + assert.equal(unsave2.json.saved, false); + + // 9. Verify removed from saved list + const idsAfter = await attendeeAlice.get('/api/me/saved-event-ids'); + assert.equal(idsAfter.json.savedIds.includes(demoEventId), false); +}); + +test('Tenant & User Isolation: Alice and Bob saved events and bookings are strictly isolated', async () => { + // Alice saves demoEventId + await attendeeAlice.post(`/api/events/${demoEventId}/save`, {}); + + // Bob's saved events must be empty + const bobSaved = await attendeeBob.get('/api/me/saved-events'); + assert.equal(bobSaved.status, 200); + assert.ok(!bobSaved.json.events.some((e) => e.id === demoEventId), "Bob cannot see Alice's saved events"); + + const bobIds = await attendeeBob.get('/api/me/saved-event-ids'); + assert.ok(!bobIds.json.savedIds.includes(demoEventId)); + + // Cleanup + await attendeeAlice.req('DELETE', `/api/events/${demoEventId}/save`, {}); +}); + +test('Attendee Experience Hub: Bookings, QR Tickets, Announcements, Check-in status', async () => { + // 1. Get free ticket tier for sample event + const tierRow = await db.one(`SELECT id FROM ticket_types WHERE event_id = $1 AND kind = 'free' LIMIT 1`, [demoEventId]); + assert.ok(tierRow, 'free tier exists for demo event'); + + // 2. Alice books the free ticket + const bookRes = await attendeeAlice.post(`/api/events/${demoEventId}/book`, { + ticketTypeId: tierRow.id, + quantity: 1, + answers: {} + }); + assert.equal(bookRes.status, 200, bookRes.text); + const bookingId = bookRes.json.bookingId; + + // 3. Verify Alice's booking list (/api/me/bookings) + const myBookingsRes = await attendeeAlice.get('/api/me/bookings'); + assert.equal(myBookingsRes.status, 200); + const myBooking = myBookingsRes.json.bookings.find((b) => b.id === bookingId); + assert.ok(myBooking, 'booking found in my bookings'); + assert.equal(myBooking.status, 'confirmed'); + assert.equal(myBooking.isCheckedIn, false); + assert.equal(myBooking.isPast, false); + + // 4. Bob cannot access Alice's booking details + const bobAccess = await attendeeBob.get(`/api/bookings/${bookingId}`); + assert.equal(bobAccess.status, 404, 'cross-user booking access denied'); + + // 5. Alice accesses booking details: QR ticket and unlocked venue + const alicePass = await attendeeAlice.get(`/api/bookings/${bookingId}`); + assert.equal(alicePass.status, 200); + assert.equal(alicePass.json.booking.id, bookingId); + assert.equal(alicePass.json.booking.status, 'confirmed'); + assert.ok(alicePass.json.booking.venue, 'confirmed booking has unlocked venue'); + assert.ok(Array.isArray(alicePass.json.booking.tickets), 'confirmed booking has digital tickets'); + assert.equal(alicePass.json.booking.tickets.length, 1); + const ticket = alicePass.json.booking.tickets[0]; + assert.ok(ticket.ticketCode, 'ticket code present'); + assert.ok(ticket.qrSvg, 'ticket QR SVG present'); + assert.equal(ticket.status, 'valid'); + + // 6. Organiser posts operational announcement + const annRes = await organiser.post(`/api/organiser/events/${demoEventId}/announcements`, { + title: 'Door Open & Entry Instructions', + message: 'Gates open at 7:00 PM. Please have your NIGHTLIFEZ QR pass ready.', + isPinned: true + }); + assert.equal(annRes.status, 201, annRes.text); + const announcementId = annRes.json.announcement.id; + + // 7. Alice sees announcement in /api/me/announcements and on /api/bookings/:id + const aliceAnn = await attendeeAlice.get('/api/me/announcements'); + assert.equal(aliceAnn.status, 200); + assert.ok(aliceAnn.json.announcements.some((a) => a.id === announcementId), 'booked attendee sees announcement'); + + const alicePassWithAnn = await attendeeAlice.get(`/api/bookings/${bookingId}`); + assert.ok(alicePassWithAnn.json.booking.announcements.some((a) => a.id === announcementId)); + + // 8. Bob (not booked) does NOT see the announcement in /api/me/announcements + const bobAnn = await attendeeBob.get('/api/me/announcements'); + assert.equal(bobAnn.status, 200); + assert.ok(!bobAnn.json.announcements.some((a) => a.id === announcementId), 'unbooked user does not see announcement'); + + // 9. Door check-in simulation: Check-in ticket using organiser staff validation + const checkinRes = await organiser.post(`/api/organiser/events/${demoEventId}/check-in`, { + ticketCode: ticket.ticketCode, + method: 'manual_lookup' + }); + assert.equal(checkinRes.status, 200, checkinRes.text); + assert.equal(checkinRes.json.result, 'success'); + + // 10. Verify Alice's pass now reflects checked-in status + const checkedInPass = await attendeeAlice.get(`/api/bookings/${bookingId}`); + assert.equal(checkedInPass.status, 200); + assert.equal(checkedInPass.json.booking.isCheckedIn, true); + assert.ok(checkedInPass.json.booking.checkedInAt, 'checkedInAt timestamp recorded'); + assert.equal(checkedInPass.json.booking.tickets[0].status, 'checked_in'); + + const checkedInList = await attendeeAlice.get('/api/me/bookings'); + const checkedInItem = checkedInList.json.bookings.find((b) => b.id === bookingId); + assert.equal(checkedInItem.isCheckedIn, true); +}); + +test('UI Page Rendering: /me attendee hub, /events discovery filters, /events/:id save button', async () => { + // 1. /me page renders tabbed attendee hub and maintains backward-compatible selectors + const meRes = await attendeeAlice.get('/me'); + assert.equal(meRes.status, 200); + assert.match(meRes.text, /data-attendee-hub/, 'contains attendee hub container'); + assert.match(meRes.text, /data-hub-tab="bookings"/, 'contains bookings tab'); + assert.match(meRes.text, /data-hub-tab="saved"/, 'contains saved events tab'); + assert.match(meRes.text, /data-hub-tab="announcements"/, 'contains host updates tab'); + assert.match(meRes.text, /data-booking-filter="all"/, 'contains all bookings filter'); + assert.match(meRes.text, /data-booking-filter="upcoming"/, 'contains upcoming bookings filter'); + assert.match(meRes.text, /data-booking-filter="past"/, 'contains past bookings filter'); + // Invariant: safety-block and profile-block must remain for E2E tests + assert.match(meRes.text, /data-profile/, 'contains data-profile container'); + assert.match(meRes.text, /data-safety-settings/, 'contains data-safety-settings container'); + + // 2. /events page renders multi-filter controls + const eventsRes = await attendeeAlice.get('/events'); + assert.equal(eventsRes.status, 200); + assert.match(eventsRes.text, /name="price"/, 'contains price filter'); + assert.match(eventsRes.text, /name="sort"/, 'contains sort filter'); + assert.match(eventsRes.text, /name="category"/, 'contains category filter'); + assert.match(eventsRes.text, /name="city"/, 'contains city filter'); + assert.match(eventsRes.text, /name="when"/, 'contains when filter'); + + // 3. /events/:slug detail page renders save button and badges + const detailRes = await attendeeAlice.get(`/events/${sampleSlug}`); + assert.equal(detailRes.status, 200); + assert.match(detailRes.text, /btn-save-event/, 'contains save button in title row'); + assert.match(detailRes.text, /data-save-event/, 'save button has event ID attribute'); +}); + +test('Search & Sorting: query search, sort by trending, date_asc, and price_low', async () => { + const c = new Client(); + + // 1. Text search + const searchRes = await c.get('/api/discovery/feed?q=mixer'); + assert.equal(searchRes.status, 200); + assert.ok(Array.isArray(searchRes.json.events)); + + // 2. Sort by date_asc + const dateRes = await c.get('/api/discovery/feed?sort=date_asc'); + assert.equal(dateRes.status, 200); + const dateEvents = dateRes.json.events; + for (let i = 1; i < dateEvents.length; i++) { + assert.ok(new Date(dateEvents[i - 1].startAt) <= new Date(dateEvents[i].startAt)); + } + + // 3. Sort by trending + const trendRes = await c.get('/api/discovery/feed?sort=trending'); + assert.equal(trendRes.status, 200); + assert.ok(Array.isArray(trendRes.json.trending)); + + // 4. Sort by price_low + const priceRes = await c.get('/api/discovery/feed?sort=price_low'); + assert.equal(priceRes.status, 200); + assert.ok(priceRes.json.events.length >= 0); +}); + +test('Saved Events Error Handling: invalid UUIDs and non-existent events', async () => { + // Invalid event ID format + const badIdRes = await attendeeAlice.post('/api/events/not-a-uuid/save', {}); + assert.equal(badIdRes.status, 400); + + // Non-existent event ID + const missingIdRes = await attendeeAlice.post('/api/events/00000000-0000-0000-0000-000000000099/save', {}); + assert.equal(missingIdRes.status, 404); + + // Invalid delete + const badDel = await attendeeAlice.req('DELETE', '/api/events/not-a-uuid/save', {}); + assert.equal(badDel.status, 400); +}); + diff --git a/test/migrations.test.js b/test/migrations.test.js index a21164a..22418a6 100644 --- a/test/migrations.test.js +++ b/test/migrations.test.js @@ -20,13 +20,13 @@ test('migration files inventory: sequential numbering, correct naming, and sorte .filter((f) => /^\d+_.*\.sql$/.test(f)) .sort(); - // Exactly 17 migrations currently exist: 003 through 019 - assert.equal(files.length, 17, `expected 17 migration files, got ${files.length}: ${files.join(', ')}`); + // Exactly 18 migrations currently exist: 003 through 020 + assert.equal(files.length, 18, `expected 18 migration files, got ${files.length}: ${files.join(', ')}`); // Verify sequential ordering const expectedPrefixes = [ '003_', '004_', '005_', '006_', '007_', '008_', - '009_', '010_', '011_', '012_', '013_', '014_', '015_', '016_', '017_', '018_', '019_' + '009_', '010_', '011_', '012_', '013_', '014_', '015_', '016_', '017_', '018_', '019_', '020_' ]; for (let i = 0; i < expectedPrefixes.length; i++) { assert.ok( @@ -45,9 +45,9 @@ test('migration idempotency: re-running migrate() on an up-to-date database appl assert.deepEqual(thirdRun, [], 'expected zero migrations applied on third run'); }); -test('schema_migrations table records all 17 migrations in correct order', async () => { +test('schema_migrations table records all 18 migrations in correct order', async () => { const { rows } = await db.query('SELECT name, applied_at FROM schema_migrations ORDER BY name ASC'); - assert.equal(rows.length, 17); + assert.equal(rows.length, 18); const appliedNames = rows.map((r) => r.name); const filesOnDisk = fs.readdirSync(MIGRATIONS_DIR) @@ -74,9 +74,9 @@ test('advisory lock serializes concurrent migrate() calls', async () => { assert.deepEqual(res2, []); assert.deepEqual(res3, []); - // Total count in schema_migrations must remain exactly 17 + // Total count in schema_migrations must remain exactly 18 const countRes = await db.one('SELECT count(*)::int AS n FROM schema_migrations'); - assert.equal(countRes.n, 17); + assert.equal(countRes.n, 18); }); test('migration policy respects environment boundaries', () => { @@ -127,7 +127,7 @@ test('core schema integrity: all tables, custom types, and location intelligence 'reports', 'event_drafts', 'user_sessions', 'organisations', 'org_memberships', 'user_blocks', 'location_states', 'location_districts', 'location_cities', 'location_localities', - 'payment_audits' + 'payment_audits', 'saved_events' ]; for (const table of expectedTables) {