Skip to content

Commit c416340

Browse files
committed
Merge branch 'main' into copilot/expand-global-search-feature
2 parents a78d016 + 7471aa4 commit c416340

104 files changed

Lines changed: 2421 additions & 358 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/copilot-setup-steps.yml

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,3 @@ jobs:
4646
4747
- name: Install dependencies
4848
run: pnpm install --frozen-lockfile
49-
50-
- name: Install Playwright browsers
51-
run: pnpm exec playwright install --with-deps chromium
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
vi.mock('@/lib/auth', () => ({
2+
getAuthSession: vi.fn().mockResolvedValue(null),
3+
}))
4+
5+
import { isClientError } from '@/server/trpc'
6+
7+
describe('tRPC error handler', () => {
8+
describe('isClientError', () => {
9+
it.each([
10+
'NOT_FOUND',
11+
'BAD_REQUEST',
12+
'UNAUTHORIZED',
13+
'FORBIDDEN',
14+
'PARSE_ERROR',
15+
])('should classify %s as a client error', (code) => {
16+
expect(isClientError(code)).toBe(true)
17+
})
18+
19+
it.each(['INTERNAL_SERVER_ERROR', 'TIMEOUT', 'TOO_MANY_REQUESTS'])(
20+
'should not classify %s as a client error',
21+
(code) => {
22+
expect(isClientError(code)).toBe(false)
23+
},
24+
)
25+
})
26+
})

__tests__/components/admin/sponsor-crm/SponsorContractView.test.tsx

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,10 +141,20 @@ describe('SponsorContractView', () => {
141141
})
142142

143143
it('shows portal section when registration not complete', () => {
144-
renderView({ registrationComplete: false })
144+
renderView({ registrationComplete: false, status: 'closed-won' })
145145
expect(screen.getByTestId('portal-section')).toBeInTheDocument()
146146
})
147147

148+
it('shows warning instead of portal when sponsor is not closed-won', () => {
149+
renderView({ registrationComplete: false, status: 'negotiating' })
150+
expect(
151+
screen.getByText(
152+
/Move the sponsor to Closed Won before sending registration/,
153+
),
154+
).toBeInTheDocument()
155+
expect(screen.queryByTestId('portal-section')).not.toBeInTheDocument()
156+
})
157+
148158
it('shows completion message when registration is done', () => {
149159
renderView({
150160
registrationComplete: true,
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import { isScheduleInPast, isScheduleToday } from '@/lib/program/time-utils'
2+
3+
describe('program/time-utils.ts', () => {
4+
describe('isScheduleInPast', () => {
5+
it('should return true for dates in the past', () => {
6+
const currentTime = new Date('2025-10-28T12:00:00')
7+
const scheduleDate = '2025-10-27'
8+
expect(isScheduleInPast(scheduleDate, currentTime)).toBe(true)
9+
})
10+
11+
it('should return false for today', () => {
12+
const currentTime = new Date('2025-10-27T12:00:00')
13+
const scheduleDate = '2025-10-27'
14+
expect(isScheduleInPast(scheduleDate, currentTime)).toBe(false)
15+
})
16+
17+
it('should return false for dates in the future', () => {
18+
const currentTime = new Date('2025-10-27T12:00:00')
19+
const scheduleDate = '2025-10-28'
20+
expect(isScheduleInPast(scheduleDate, currentTime)).toBe(false)
21+
})
22+
23+
it('should handle dates far in the past', () => {
24+
const currentTime = new Date('2025-10-27T12:00:00')
25+
const scheduleDate = '2024-01-01'
26+
expect(isScheduleInPast(scheduleDate, currentTime)).toBe(true)
27+
})
28+
29+
it('should handle dates far in the future', () => {
30+
const currentTime = new Date('2025-10-27T12:00:00')
31+
const scheduleDate = '2026-12-31'
32+
expect(isScheduleInPast(scheduleDate, currentTime)).toBe(false)
33+
})
34+
35+
it('should ignore time of day (only compare dates)', () => {
36+
// Morning
37+
const morningTime = new Date('2025-10-28T08:00:00')
38+
expect(isScheduleInPast('2025-10-27', morningTime)).toBe(true)
39+
40+
// Evening
41+
const eveningTime = new Date('2025-10-28T23:59:59')
42+
expect(isScheduleInPast('2025-10-27', eveningTime)).toBe(true)
43+
44+
// Today should still be false regardless of time
45+
expect(isScheduleInPast('2025-10-28', morningTime)).toBe(false)
46+
expect(isScheduleInPast('2025-10-28', eveningTime)).toBe(false)
47+
})
48+
})
49+
50+
describe('isScheduleToday', () => {
51+
it('should return true when schedule date matches current date', () => {
52+
const currentTime = new Date('2025-10-27T12:00:00')
53+
const scheduleDate = '2025-10-27'
54+
expect(isScheduleToday(scheduleDate, currentTime)).toBe(true)
55+
})
56+
57+
it('should return false when schedule date is in the past', () => {
58+
const currentTime = new Date('2025-10-28T12:00:00')
59+
const scheduleDate = '2025-10-27'
60+
expect(isScheduleToday(scheduleDate, currentTime)).toBe(false)
61+
})
62+
63+
it('should return false when schedule date is in the future', () => {
64+
const currentTime = new Date('2025-10-27T12:00:00')
65+
const scheduleDate = '2025-10-28'
66+
expect(isScheduleToday(scheduleDate, currentTime)).toBe(false)
67+
})
68+
})
69+
70+
describe('integration: isScheduleInPast and isScheduleToday', () => {
71+
it('should have mutually exclusive results for past and today', () => {
72+
const currentTime = new Date('2025-10-27T12:00:00')
73+
const yesterdayDate = '2025-10-26'
74+
const todayDate = '2025-10-27'
75+
const tomorrowDate = '2025-10-28'
76+
77+
// Yesterday
78+
expect(isScheduleInPast(yesterdayDate, currentTime)).toBe(true)
79+
expect(isScheduleToday(yesterdayDate, currentTime)).toBe(false)
80+
81+
// Today
82+
expect(isScheduleInPast(todayDate, currentTime)).toBe(false)
83+
expect(isScheduleToday(todayDate, currentTime)).toBe(true)
84+
85+
// Tomorrow
86+
expect(isScheduleInPast(tomorrowDate, currentTime)).toBe(false)
87+
expect(isScheduleToday(tomorrowDate, currentTime)).toBe(false)
88+
})
89+
})
90+
})

__tests__/lib/proposal/schemas.test.ts

Lines changed: 56 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -121,21 +121,75 @@ describe('ProposalInputSchema (strict)', () => {
121121
expect(result.success).toBe(false)
122122
})
123123

124-
it('requires capacity for workshop formats', () => {
124+
it('accepts workshop format with capacity', () => {
125+
const result = ProposalInputSchema.safeParse({
126+
...fullProposal,
127+
format: Format.workshop_120,
128+
capacity: 30,
129+
})
130+
expect(result.success).toBe(true)
131+
})
132+
133+
it('accepts workshop format without capacity', () => {
134+
const result = ProposalInputSchema.safeParse({
135+
...fullProposal,
136+
format: Format.workshop_120,
137+
})
138+
expect(result.success).toBe(true)
139+
})
140+
141+
it('accepts workshop format with prerequisites', () => {
125142
const result = ProposalInputSchema.safeParse({
126143
...fullProposal,
127144
format: Format.workshop_120,
145+
capacity: 30,
146+
prerequisites: 'Bring a computer with Docker installed',
147+
})
148+
expect(result.success).toBe(true)
149+
})
150+
151+
it('accepts non-workshop format without prerequisites', () => {
152+
const result = ProposalInputSchema.safeParse({
153+
...fullProposal,
154+
format: Format.presentation_40,
155+
prerequisites: undefined,
156+
})
157+
expect(result.success).toBe(true)
158+
})
159+
160+
it('rejects prerequisites for non-workshop formats', () => {
161+
const result = ProposalInputSchema.safeParse({
162+
...fullProposal,
163+
format: Format.presentation_40,
164+
prerequisites: 'Should not be allowed',
128165
})
129166
expect(result.success).toBe(false)
130167
})
131168

132-
it('accepts workshop format with capacity', () => {
169+
it('normalizes empty prerequisites to undefined', () => {
170+
const result = ProposalInputSchema.safeParse({
171+
...fullProposal,
172+
format: Format.workshop_120,
173+
capacity: 30,
174+
prerequisites: ' ',
175+
})
176+
expect(result.success).toBe(true)
177+
if (result.success) {
178+
expect(result.data.prerequisites).toBeUndefined()
179+
}
180+
})
181+
182+
it('trims whitespace from prerequisites', () => {
133183
const result = ProposalInputSchema.safeParse({
134184
...fullProposal,
135185
format: Format.workshop_120,
136186
capacity: 30,
187+
prerequisites: ' Docker required ',
137188
})
138189
expect(result.success).toBe(true)
190+
if (result.success) {
191+
expect(result.data.prerequisites).toBe('Docker required')
192+
}
139193
})
140194

141195
it('rejects missing title', () => {

__tests__/lib/proposal/state-machine.test.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,11 @@ describe('actionStateMachine', () => {
4444
expect(result).toEqual({ status: Status.accepted, isValidAction: true })
4545
})
4646

47+
it('allows organizer to waitlist', () => {
48+
const result = actionStateMachine(Status.submitted, Action.waitlist, true)
49+
expect(result).toEqual({ status: Status.waitlisted, isValidAction: true })
50+
})
51+
4752
it('allows organizer to reject', () => {
4853
const result = actionStateMachine(Status.submitted, Action.reject, true)
4954
expect(result).toEqual({ status: Status.rejected, isValidAction: true })
@@ -54,6 +59,15 @@ describe('actionStateMachine', () => {
5459
expect(result.isValidAction).toBe(false)
5560
})
5661

62+
it('prevents non-organizer from waitlisting', () => {
63+
const result = actionStateMachine(
64+
Status.submitted,
65+
Action.waitlist,
66+
false,
67+
)
68+
expect(result.isValidAction).toBe(false)
69+
})
70+
5771
it('prevents non-organizer from rejecting', () => {
5872
const result = actionStateMachine(Status.submitted, Action.reject, false)
5973
expect(result.isValidAction).toBe(false)
@@ -110,6 +124,37 @@ describe('actionStateMachine', () => {
110124
})
111125
})
112126

127+
describe('waitlisted status', () => {
128+
it('allows organizer to accept', () => {
129+
const result = actionStateMachine(Status.waitlisted, Action.accept, true)
130+
expect(result).toEqual({ status: Status.accepted, isValidAction: true })
131+
})
132+
133+
it('allows organizer to reject', () => {
134+
const result = actionStateMachine(Status.waitlisted, Action.reject, true)
135+
expect(result).toEqual({ status: Status.rejected, isValidAction: true })
136+
})
137+
138+
it('allows speaker to withdraw', () => {
139+
const result = actionStateMachine(
140+
Status.waitlisted,
141+
Action.withdraw,
142+
false,
143+
)
144+
expect(result).toEqual({ status: Status.withdrawn, isValidAction: true })
145+
})
146+
147+
it('prevents non-organizer from accepting', () => {
148+
const result = actionStateMachine(Status.waitlisted, Action.accept, false)
149+
expect(result.isValidAction).toBe(false)
150+
})
151+
152+
it('prevents non-organizer from rejecting', () => {
153+
const result = actionStateMachine(Status.waitlisted, Action.reject, false)
154+
expect(result.isValidAction).toBe(false)
155+
})
156+
})
157+
113158
describe('full lifecycle: draft → submitted → accepted → confirmed', () => {
114159
it('transitions through the complete happy path', () => {
115160
let { status } = actionStateMachine(Status.draft, Action.submit, false)
@@ -119,5 +164,14 @@ describe('actionStateMachine', () => {
119164
;({ status } = actionStateMachine(status, Action.confirm, false))
120165
expect(status).toBe(Status.confirmed)
121166
})
167+
168+
it('transitions through waitlist path: draft → submitted → waitlisted → accepted', () => {
169+
let { status } = actionStateMachine(Status.draft, Action.submit, false)
170+
expect(status).toBe(Status.submitted)
171+
;({ status } = actionStateMachine(status, Action.waitlist, true))
172+
expect(status).toBe(Status.waitlisted)
173+
;({ status } = actionStateMachine(status, Action.accept, true))
174+
expect(status).toBe(Status.accepted)
175+
})
122176
})
123177
})

__tests__/lib/proposal/validation.test.ts

Lines changed: 0 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -103,42 +103,6 @@ describe('validateProposalForm', () => {
103103
expect(errors.tos).toBe(PROPOSAL_VALIDATION_MESSAGES.TOS_REQUIRED)
104104
})
105105

106-
it('returns capacity error for workshop_120 without capacity', () => {
107-
const errors = validateProposalForm(
108-
makeValidProposal({ format: Format.workshop_120 }),
109-
)
110-
expect(errors.capacity).toBe(PROPOSAL_VALIDATION_MESSAGES.CAPACITY_REQUIRED)
111-
})
112-
113-
it('returns capacity error for workshop_240 without capacity', () => {
114-
const errors = validateProposalForm(
115-
makeValidProposal({ format: Format.workshop_240 }),
116-
)
117-
expect(errors.capacity).toBe(PROPOSAL_VALIDATION_MESSAGES.CAPACITY_REQUIRED)
118-
})
119-
120-
it('accepts workshop format with capacity', () => {
121-
const errors = validateProposalForm(
122-
makeValidProposal({ format: Format.workshop_120, capacity: 30 }),
123-
)
124-
expect(errors.capacity).toBeUndefined()
125-
})
126-
127-
it('does not return capacity error for non-workshop formats', () => {
128-
const errors = validateProposalForm(
129-
makeValidProposal({ format: Format.presentation_40 }),
130-
)
131-
expect(errors.capacity).toBeUndefined()
132-
})
133-
134-
it('suppresses capacity check when requireCapacity is false', () => {
135-
const errors = validateProposalForm(
136-
makeValidProposal({ format: Format.workshop_120 }),
137-
{ requireCapacity: false },
138-
)
139-
expect(errors.capacity).toBeUndefined()
140-
})
141-
142106
it('returns multiple errors simultaneously', () => {
143107
const errors = validateProposalForm(
144108
makeValidProposal({
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { speakerImageUrl } from '@/lib/sanity/client'
3+
4+
describe('speakerImageUrl', () => {
5+
const sanityCdnUrl =
6+
'https://cdn.sanity.io/images/testproject/production/abc123-200x200.jpg'
7+
const githubAvatarUrl = 'https://avatars.githubusercontent.com/u/12345?v=4'
8+
const linkedinAvatarUrl =
9+
'https://media.licdn.com/dms/image/v2/abc123/profile-photo.jpg'
10+
11+
it('should transform Sanity CDN URLs through the image builder', () => {
12+
const result = speakerImageUrl(sanityCdnUrl)
13+
14+
expect(result).toContain('cdn.sanity.io')
15+
})
16+
17+
it('should return GitHub avatar URLs unchanged', () => {
18+
expect(speakerImageUrl(githubAvatarUrl)).toBe(githubAvatarUrl)
19+
})
20+
21+
it('should return LinkedIn avatar URLs unchanged', () => {
22+
expect(speakerImageUrl(linkedinAvatarUrl)).toBe(linkedinAvatarUrl)
23+
})
24+
25+
it('should not transform non-Sanity URLs even with custom options', () => {
26+
const result = speakerImageUrl(githubAvatarUrl, {
27+
width: 800,
28+
height: 800,
29+
fit: 'crop',
30+
})
31+
32+
expect(result).toBe(githubAvatarUrl)
33+
})
34+
35+
it('should not transform plain http URLs', () => {
36+
const httpUrl = 'http://example.com/photo.jpg'
37+
expect(speakerImageUrl(httpUrl)).toBe(httpUrl)
38+
})
39+
})

0 commit comments

Comments
 (0)