1+ import { describe , it , expect , vi , beforeEach } from "vitest" ;
2+
3+ import { CreditsRepository } from "@calcom/features/credits/repositories/CreditsRepository" ;
4+ import { sendVerificationCode } from "@calcom/features/ee/workflows/lib/reminders/verifyPhoneNumber" ;
5+ import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError" ;
6+
7+ import { TRPCError } from "@trpc/server" ;
8+
9+ import { hasTeamPlanHandler } from "../teams/hasTeamPlan.handler" ;
10+ import { sendVerificationCodeHandler } from "./sendVerificationCode.handler" ;
11+
12+ vi . mock ( "@calcom/lib/checkRateLimitAndThrowError" ) ;
13+ vi . mock ( "@calcom/features/credits/repositories/CreditsRepository" ) ;
14+ vi . mock ( "@calcom/features/ee/workflows/lib/reminders/verifyPhoneNumber" ) ;
15+ vi . mock ( "../teams/hasTeamPlan.handler" ) ;
16+
17+ describe ( "sendVerificationCodeHandler" , ( ) => {
18+ const mockUser = {
19+ id : 123 ,
20+ name : "Test User" ,
21+ email : "test@example.com" ,
22+ metadata : { } ,
23+ } ;
24+
25+ const mockCtx = {
26+ user : mockUser ,
27+ } ;
28+
29+ const mockInput = {
30+ phoneNumber : "+1234567890" ,
31+ } ;
32+
33+ beforeEach ( ( ) => {
34+ vi . clearAllMocks ( ) ;
35+ vi . mocked ( checkRateLimitAndThrowError ) . mockResolvedValue ( {
36+ success : true ,
37+ remaining : 99 ,
38+ limit : 100 ,
39+ reset : Date . now ( ) + 60 * 1000 ,
40+ } ) ;
41+ vi . mocked ( CreditsRepository . findCreditBalance ) . mockResolvedValue ( {
42+ id : 1 ,
43+ additionalCredits : 100 ,
44+ balance : 100 ,
45+ userId : mockUser . id ,
46+ teamId : null ,
47+ limitReachedAt : null ,
48+ warningSentAt : null ,
49+ lockedAt : null ,
50+ } ) ;
51+ vi . mocked ( hasTeamPlanHandler ) . mockResolvedValue ( { hasTeamPlan : false } ) ;
52+ vi . mocked ( sendVerificationCode ) . mockResolvedValue ( { status : "pending" } ) ;
53+ } ) ;
54+
55+ describe ( "Rate Limiting" , ( ) => {
56+ it ( "should check SMS rate limit with 'sms' type before processing" , async ( ) => {
57+ await sendVerificationCodeHandler ( { ctx : mockCtx , input : mockInput } ) ;
58+
59+ expect ( checkRateLimitAndThrowError ) . toHaveBeenCalledWith ( {
60+ identifier : `sms:verification:${ mockUser . id } ` ,
61+ rateLimitingType : "sms" ,
62+ } ) ;
63+ } ) ;
64+
65+ it ( "should check SMS rate limit with 'smsMonth' type before processing" , async ( ) => {
66+ await sendVerificationCodeHandler ( { ctx : mockCtx , input : mockInput } ) ;
67+
68+ expect ( checkRateLimitAndThrowError ) . toHaveBeenCalledWith ( {
69+ identifier : `sms:verification:${ mockUser . id } ` ,
70+ rateLimitingType : "smsMonth" ,
71+ } ) ;
72+ } ) ;
73+
74+ it ( "should check both rate limits in order (sms first, then smsMonth)" , async ( ) => {
75+ const callOrder : string [ ] = [ ] ;
76+ vi . mocked ( checkRateLimitAndThrowError ) . mockImplementation ( async ( { rateLimitingType } ) => {
77+ callOrder . push ( rateLimitingType as string ) ;
78+ return { success : true , remaining : 99 , limit : 100 , reset : Date . now ( ) + 60 * 1000 } ;
79+ } ) ;
80+
81+ await sendVerificationCodeHandler ( { ctx : mockCtx , input : mockInput } ) ;
82+
83+ expect ( callOrder ) . toEqual ( [ "sms" , "smsMonth" ] ) ;
84+ } ) ;
85+
86+ it ( "should check rate limits before billing gate (credits check)" , async ( ) => {
87+ const callOrder : string [ ] = [ ] ;
88+ vi . mocked ( checkRateLimitAndThrowError ) . mockImplementation ( async ( ) => {
89+ callOrder . push ( "rateLimit" ) ;
90+ return { success : true , remaining : 99 , limit : 100 , reset : Date . now ( ) + 60 * 1000 } ;
91+ } ) ;
92+ vi . mocked ( CreditsRepository . findCreditBalance ) . mockImplementation ( async ( ) => {
93+ callOrder . push ( "credits" ) ;
94+ return {
95+ id : 1 ,
96+ additionalCredits : 100 ,
97+ balance : 100 ,
98+ userId : mockUser . id ,
99+ teamId : null ,
100+ limitReachedAt : null ,
101+ warningSentAt : null ,
102+ lockedAt : null ,
103+ } ;
104+ } ) ;
105+
106+ await sendVerificationCodeHandler ( { ctx : mockCtx , input : mockInput } ) ;
107+
108+ expect ( callOrder . indexOf ( "rateLimit" ) ) . toBeLessThan ( callOrder . indexOf ( "credits" ) ) ;
109+ } ) ;
110+
111+ it ( "should throw and not check billing when sms rate limit fails" , async ( ) => {
112+ vi . mocked ( checkRateLimitAndThrowError ) . mockRejectedValueOnce (
113+ new Error ( "Rate limit exceeded. Try again in 60 seconds." )
114+ ) ;
115+
116+ await expect ( sendVerificationCodeHandler ( { ctx : mockCtx , input : mockInput } ) ) . rejects . toThrow (
117+ "Rate limit exceeded"
118+ ) ;
119+
120+ expect ( CreditsRepository . findCreditBalance ) . not . toHaveBeenCalled ( ) ;
121+ expect ( hasTeamPlanHandler ) . not . toHaveBeenCalled ( ) ;
122+ expect ( sendVerificationCode ) . not . toHaveBeenCalled ( ) ;
123+ } ) ;
124+
125+ it ( "should throw and not check billing when smsMonth rate limit fails" , async ( ) => {
126+ vi . mocked ( checkRateLimitAndThrowError )
127+ . mockResolvedValueOnce ( { success : true , remaining : 99 , limit : 100 , reset : Date . now ( ) } )
128+ . mockRejectedValueOnce ( new Error ( "Rate limit exceeded. Try again in 2592000 seconds." ) ) ;
129+
130+ await expect ( sendVerificationCodeHandler ( { ctx : mockCtx , input : mockInput } ) ) . rejects . toThrow (
131+ "Rate limit exceeded"
132+ ) ;
133+
134+ expect ( CreditsRepository . findCreditBalance ) . not . toHaveBeenCalled ( ) ;
135+ expect ( hasTeamPlanHandler ) . not . toHaveBeenCalled ( ) ;
136+ expect ( sendVerificationCode ) . not . toHaveBeenCalled ( ) ;
137+ } ) ;
138+ } ) ;
139+
140+ describe ( "Authorization - Premium Users" , ( ) => {
141+ it ( "should allow premium users to send verification code" , async ( ) => {
142+ const premiumUser = {
143+ ...mockUser ,
144+ metadata : { isPremium : true } ,
145+ } ;
146+
147+ await sendVerificationCodeHandler ( { ctx : { user : premiumUser } , input : mockInput } ) ;
148+
149+ expect ( sendVerificationCode ) . toHaveBeenCalledWith ( mockInput . phoneNumber ) ;
150+ // Premium users skip team plan check
151+ expect ( hasTeamPlanHandler ) . not . toHaveBeenCalled ( ) ;
152+ } ) ;
153+
154+ it ( "should allow premium users even with no additional credits" , async ( ) => {
155+ const premiumUser = {
156+ ...mockUser ,
157+ metadata : { isPremium : true } ,
158+ } ;
159+ vi . mocked ( CreditsRepository . findCreditBalance ) . mockResolvedValue ( {
160+ id : 1 ,
161+ additionalCredits : 0 ,
162+ balance : 0 ,
163+ userId : mockUser . id ,
164+ teamId : null ,
165+ limitReachedAt : null ,
166+ warningSentAt : null ,
167+ lockedAt : null ,
168+ } ) ;
169+
170+ await sendVerificationCodeHandler ( { ctx : { user : premiumUser } , input : mockInput } ) ;
171+
172+ expect ( sendVerificationCode ) . toHaveBeenCalledWith ( mockInput . phoneNumber ) ;
173+ } ) ;
174+ } ) ;
175+
176+ describe ( "Authorization - Team Plan Users" , ( ) => {
177+ it ( "should allow team plan users to send verification code" , async ( ) => {
178+ vi . mocked ( hasTeamPlanHandler ) . mockResolvedValue ( { hasTeamPlan : true } ) ;
179+ vi . mocked ( CreditsRepository . findCreditBalance ) . mockResolvedValue ( {
180+ id : 1 ,
181+ additionalCredits : 0 ,
182+ balance : 0 ,
183+ userId : mockUser . id ,
184+ teamId : null ,
185+ limitReachedAt : null ,
186+ warningSentAt : null ,
187+ lockedAt : null ,
188+ } ) ;
189+
190+ await sendVerificationCodeHandler ( { ctx : mockCtx , input : mockInput } ) ;
191+
192+ expect ( sendVerificationCode ) . toHaveBeenCalledWith ( mockInput . phoneNumber ) ;
193+ } ) ;
194+
195+ it ( "should check team plan for non-premium users" , async ( ) => {
196+ await sendVerificationCodeHandler ( { ctx : mockCtx , input : mockInput } ) ;
197+
198+ expect ( hasTeamPlanHandler ) . toHaveBeenCalledWith ( { ctx : mockCtx } ) ;
199+ } ) ;
200+ } ) ;
201+
202+ describe ( "Authorization - Users with Credits" , ( ) => {
203+ it ( "should allow users with additional credits to send verification code" , async ( ) => {
204+ vi . mocked ( CreditsRepository . findCreditBalance ) . mockResolvedValue ( {
205+ id : 1 ,
206+ additionalCredits : 50 ,
207+ balance : 50 ,
208+ userId : mockUser . id ,
209+ teamId : null ,
210+ limitReachedAt : null ,
211+ warningSentAt : null ,
212+ lockedAt : null ,
213+ } ) ;
214+ vi . mocked ( hasTeamPlanHandler ) . mockResolvedValue ( { hasTeamPlan : false } ) ;
215+
216+ await sendVerificationCodeHandler ( { ctx : mockCtx , input : mockInput } ) ;
217+
218+ expect ( sendVerificationCode ) . toHaveBeenCalledWith ( mockInput . phoneNumber ) ;
219+ } ) ;
220+ } ) ;
221+
222+ describe ( "Authorization - Unauthorized Users" , ( ) => {
223+ it ( "should throw UNAUTHORIZED when user is not premium, has no team plan, and no credits" , async ( ) => {
224+ vi . mocked ( CreditsRepository . findCreditBalance ) . mockResolvedValue ( {
225+ id : 1 ,
226+ additionalCredits : 0 ,
227+ balance : 0 ,
228+ userId : mockUser . id ,
229+ teamId : null ,
230+ limitReachedAt : null ,
231+ warningSentAt : null ,
232+ lockedAt : null ,
233+ } ) ;
234+ vi . mocked ( hasTeamPlanHandler ) . mockResolvedValue ( { hasTeamPlan : false } ) ;
235+
236+ await expect ( sendVerificationCodeHandler ( { ctx : mockCtx , input : mockInput } ) ) . rejects . toThrow (
237+ new TRPCError ( { code : "UNAUTHORIZED" } )
238+ ) ;
239+
240+ expect ( sendVerificationCode ) . not . toHaveBeenCalled ( ) ;
241+ } ) ;
242+
243+ it ( "should throw UNAUTHORIZED when creditBalance has negative additionalCredits" , async ( ) => {
244+ vi . mocked ( CreditsRepository . findCreditBalance ) . mockResolvedValue ( {
245+ id : 1 ,
246+ additionalCredits : - 5 ,
247+ balance : 0 ,
248+ userId : mockUser . id ,
249+ teamId : null ,
250+ limitReachedAt : null ,
251+ warningSentAt : null ,
252+ lockedAt : null ,
253+ } ) ;
254+ vi . mocked ( hasTeamPlanHandler ) . mockResolvedValue ( { hasTeamPlan : false } ) ;
255+
256+ await expect ( sendVerificationCodeHandler ( { ctx : mockCtx , input : mockInput } ) ) . rejects . toThrow (
257+ new TRPCError ( { code : "UNAUTHORIZED" } )
258+ ) ;
259+ } ) ;
260+ } ) ;
261+
262+ describe ( "Send Verification Code" , ( ) => {
263+ it ( "should call sendVerificationCode with the phone number from input" , async ( ) => {
264+ await sendVerificationCodeHandler ( { ctx : mockCtx , input : mockInput } ) ;
265+
266+ expect ( sendVerificationCode ) . toHaveBeenCalledWith ( mockInput . phoneNumber ) ;
267+ } ) ;
268+
269+ it ( "should return the result from sendVerificationCode" , async ( ) => {
270+ const expectedResult = { status : "pending" , sid : "verification-sid" } ;
271+ vi . mocked ( sendVerificationCode ) . mockResolvedValue ( expectedResult ) ;
272+
273+ const result = await sendVerificationCodeHandler ( { ctx : mockCtx , input : mockInput } ) ;
274+
275+ expect ( result ) . toEqual ( expectedResult ) ;
276+ } ) ;
277+ } ) ;
278+
279+ describe ( "Edge Cases" , ( ) => {
280+ it ( "should handle null credit balance gracefully" , async ( ) => {
281+ vi . mocked ( CreditsRepository . findCreditBalance ) . mockResolvedValue ( null ) ;
282+ vi . mocked ( hasTeamPlanHandler ) . mockResolvedValue ( { hasTeamPlan : true } ) ;
283+
284+ await sendVerificationCodeHandler ( { ctx : mockCtx , input : mockInput } ) ;
285+
286+ expect ( sendVerificationCode ) . toHaveBeenCalledWith ( mockInput . phoneNumber ) ;
287+ } ) ;
288+
289+ it ( "should handle user without metadata gracefully" , async ( ) => {
290+ const userWithoutMetadata = {
291+ ...mockUser ,
292+ metadata : undefined ,
293+ } ;
294+ vi . mocked ( hasTeamPlanHandler ) . mockResolvedValue ( { hasTeamPlan : true } ) ;
295+
296+ await sendVerificationCodeHandler ( { ctx : { user : userWithoutMetadata } , input : mockInput } ) ;
297+
298+ expect ( sendVerificationCode ) . toHaveBeenCalledWith ( mockInput . phoneNumber ) ;
299+ } ) ;
300+ } ) ;
301+ } ) ;
0 commit comments