33process . env . REDIS_URL = process . env . REDIS_URL || "redis://localhost:6379/0" ;
44process . env . ENCRYPTION_KEY = process . env . ENCRYPTION_KEY || "0123456789012345678901234567890a" ;
55
6- jest . mock ( 'resend' , ( ) => {
7- const sendMock = jest . fn ( ( ) => Promise . resolve ( { data : { id : 'mail-123' } , error : null } ) ) ;
8- return {
9- Resend : jest . fn ( ( ) => ( {
10- emails : { send : sendMock } ,
11- } ) ) ,
12- __sendMock : sendMock ,
13- } ;
14- } ) ;
6+ // Removed resend mock because mail.controller no longer imports it
157
168jest . mock ( '@urbackend/common' , ( ) => {
179 const { sendMailSchema } = require ( '../../../../packages/common/src/utils/input.validation' ) ;
@@ -34,11 +26,13 @@ jest.mock('@urbackend/common', () => {
3426 decrypt : jest . fn ( ) ,
3527 redis : redisMock ,
3628 getPlanLimits : jest . fn ( ( ) => ( { mailPerMonth : 100 } ) ) ,
29+ publicEmailQueue : {
30+ add : jest . fn ( ( ) => Promise . resolve ( { id : 'job-123' } ) )
31+ }
3732 } ;
3833} ) ;
3934
40- const { Resend, __sendMock } = require ( 'resend' ) ;
41- const { Project, decrypt, redis } = require ( '@urbackend/common' ) ;
35+ const { Project, decrypt, redis, publicEmailQueue } = require ( '@urbackend/common' ) ;
4236const mailController = require ( '../controllers/mail.controller' ) ;
4337
4438const makeReq = ( ) => ( {
@@ -156,10 +150,82 @@ describe('mail.controller', () => {
156150 templateUsed : expect . objectContaining ( { name : 'welcome' , id : 'tpl_1' , scope : 'project' } ) ,
157151 } ) ,
158152 } ) ) ;
159- expect ( __sendMock ) . toHaveBeenCalledWith ( expect . objectContaining ( {
160- subject : 'Hello Yash' ,
161- text : 'Welcome, Yash!' ,
162- html : '<p>Welcome, Yash!</p>' ,
153+ expect ( publicEmailQueue . add ) . toHaveBeenCalledWith ( "send-public-email" , expect . objectContaining ( {
154+ payload : expect . objectContaining ( {
155+ subject : 'Hello Yash' ,
156+ text : 'Welcome, Yash!' ,
157+ html : '<p>Welcome, Yash!</p>' ,
158+ } )
159+ } ) , expect . objectContaining ( { attempts : expect . any ( Number ) } ) ) ;
160+ } ) ;
161+
162+ test ( 'refunds quota on terminal async worker failure' , async ( ) => {
163+ let failedHandler ;
164+ jest . resetModules ( ) ;
165+ jest . doMock ( 'bullmq' , ( ) => ( {
166+ Queue : jest . fn ( ) ,
167+ Worker : jest . fn ( ( ) => ( {
168+ on : jest . fn ( ( event , handler ) => {
169+ if ( event === 'failed' ) failedHandler = handler ;
170+ } ) ,
171+ removeAllListeners : jest . fn ( ) ,
172+ close : jest . fn ( )
173+ } ) )
163174 } ) ) ;
175+
176+ const mockRedis = { eval : jest . fn ( ) . mockResolvedValue ( 0 ) } ;
177+ jest . doMock ( '../../../../packages/common/src/config/redis' , ( ) => mockRedis ) ;
178+
179+ const { initPublicEmailWorker } = require ( '../../../../packages/common/src/queues/publicEmailQueue' ) ;
180+ const worker = initPublicEmailWorker ( ) ;
181+
182+ const mockJob = {
183+ id : 'job-999' ,
184+ data : { consumedQuotaKey : 'project:mail:count:proj_1:2026-05' } ,
185+ opts : { attempts : 3 } ,
186+ attemptsMade : 3
187+ } ;
188+
189+ if ( failedHandler ) {
190+ await failedHandler ( mockJob , new Error ( "Terminal failure" ) ) ;
191+ }
192+
193+ expect ( mockRedis . eval ) . toHaveBeenCalledWith (
194+ expect . any ( String ) , 1 , 'project:mail:count:proj_1:2026-05'
195+ ) ;
196+ } ) ;
197+
198+ test ( 'does not refund quota on non-terminal async worker failure' , async ( ) => {
199+ let failedHandler ;
200+ jest . resetModules ( ) ;
201+ jest . doMock ( 'bullmq' , ( ) => ( {
202+ Queue : jest . fn ( ) ,
203+ Worker : jest . fn ( ( ) => ( {
204+ on : jest . fn ( ( event , handler ) => {
205+ if ( event === 'failed' ) failedHandler = handler ;
206+ } ) ,
207+ removeAllListeners : jest . fn ( ) ,
208+ close : jest . fn ( )
209+ } ) )
210+ } ) ) ;
211+
212+ const mockRedis = { eval : jest . fn ( ) . mockResolvedValue ( 0 ) } ;
213+ jest . doMock ( '../../../../packages/common/src/config/redis' , ( ) => mockRedis ) ;
214+
215+ const { initPublicEmailWorker } = require ( '../../../../packages/common/src/queues/publicEmailQueue' ) ;
216+ const worker = initPublicEmailWorker ( ) ;
217+
218+ const mockJob = {
219+ id : 'job-888' ,
220+ data : { consumedQuotaKey : 'project:mail:count:proj_1:2026-05' } ,
221+ opts : { attempts : 3 } ,
222+ attemptsMade : 1 // Not terminal yet
223+ } ;
224+
225+ if ( failedHandler ) {
226+ await failedHandler ( mockJob , new Error ( "Temporary failure" ) ) ;
227+ }
228+
229+ expect ( mockRedis . eval ) . not . toHaveBeenCalled ( ) ;
164230 } ) ;
165231} ) ;
0 commit comments