1+ import { afterEach , beforeEach , describe , expect , test } from 'vitest' ;
2+
13import { Actor } from '../../packages/apify/src/index.js' ;
24import { MemoryStorageEmulator } from '../MemoryStorageEmulator.js' ;
35
@@ -24,6 +26,9 @@ describe('ChargingManager', () => {
2426 delete process . env . ACTOR_MAX_TOTAL_CHARGE_USD ;
2527 delete process . env . APIFY_ACTOR_PRICING_INFO ;
2628 delete process . env . APIFY_CHARGED_ACTOR_EVENT_COUNTS ;
29+ delete process . env . APIFY_IS_AT_HOME ;
30+ delete process . env . APIFY_TOKEN ;
31+ delete process . env . ACTOR_RUN_ID ;
2732 } ) ;
2833
2934 describe ( 'charge()' , ( ) => {
@@ -66,6 +71,7 @@ describe('ChargingManager', () => {
6671 process . env . APIFY_CHARGED_ACTOR_EVENT_COUNTS = JSON . stringify ( { } ) ;
6772
6873 await Actor . init ( ) ;
74+ localStorageEmulator . reapplyStorageClient ( ) ;
6975
7076 const chargeResult = await Actor . charge ( {
7177 eventName : 'foobar' ,
@@ -75,5 +81,234 @@ describe('ChargingManager', () => {
7581 expect ( chargeResult . chargedCount ) . toBe ( 4 ) ;
7682 expect ( chargeResult . eventChargeLimitReached ) . toBe ( false ) ;
7783 } ) ;
84+
85+ test ( 'should not call API when budget is exhausted during pushData' , async ( ) => {
86+ // Don't use ACTOR_TEST_PAY_PER_EVENT when simulating Apify platform
87+ delete process . env . ACTOR_TEST_PAY_PER_EVENT ;
88+
89+ process . env . ACTOR_MAX_TOTAL_CHARGE_USD = '1.5' ;
90+ process . env . APIFY_IS_AT_HOME = '1' ;
91+ process . env . APIFY_TOKEN = 'this-wont-work' ;
92+ process . env . ACTOR_RUN_ID = 'test-run-id' ;
93+
94+ // Set pricing info via env vars (as if coming from platform)
95+ process . env . APIFY_ACTOR_PRICING_INFO = JSON . stringify ( {
96+ pricingModel : 'PAY_PER_EVENT' ,
97+ pricingPerEvent : {
98+ actorChargeEvents : {
99+ 'some-event' : {
100+ eventTitle : 'Some Event' ,
101+ eventPriceUsd : 1.0 ,
102+ } ,
103+ 'another-event' : {
104+ eventTitle : 'Another Event' ,
105+ eventPriceUsd : 1.0 ,
106+ } ,
107+ } ,
108+ } ,
109+ } ) ;
110+ process . env . APIFY_CHARGED_ACTOR_EVENT_COUNTS = JSON . stringify ( { } ) ;
111+
112+ await Actor . init ( ) ;
113+ localStorageEmulator . reapplyStorageClient ( ) ;
114+
115+ // Mock the API client charge method
116+ const chargeSpy = vitest . fn ( ) . mockResolvedValue ( undefined ) ;
117+ vitest . spyOn ( Actor . apifyClient , 'run' ) . mockReturnValue ( {
118+ charge : chargeSpy ,
119+ } as any ) ;
120+
121+ // Exhaust most of the budget (events cost $1 each)
122+ const result1 = await Actor . charge ( {
123+ eventName : 'some-event' ,
124+ count : 1 ,
125+ } ) ; // Costs $1, leaving $0.5
126+ expect ( result1 . chargedCount ) . toBe ( 1 ) ;
127+ expect ( chargeSpy ) . toHaveBeenCalledTimes ( 1 ) ;
128+ expect ( chargeSpy ) . toHaveBeenCalledWith ( {
129+ eventName : 'some-event' ,
130+ count : 1 ,
131+ } ) ;
132+
133+ chargeSpy . mockClear ( ) ;
134+
135+ // Now try to push data - we can't afford even 1 more event
136+ const result = await Actor . pushData (
137+ Array ( 10 ) . fill ( { hello : 'world' } ) ,
138+ 'another-event' ,
139+ ) ;
140+
141+ // The API should NOT be called when count=0
142+ expect ( chargeSpy ) . not . toHaveBeenCalled ( ) ;
143+
144+ // Correctly returns result with chargedCount=0
145+ expect ( result ) . toBeDefined ( ) ;
146+ expect ( result ! . chargedCount ) . toBe ( 0 ) ;
147+ // Note: eventChargeLimitReached is false because count=0 was passed to charge()
148+ // This is by design - see charging.ts line 318
149+ expect ( result ! . eventChargeLimitReached ) . toBe ( false ) ;
150+
151+ // Verify no items were pushed (the important part)
152+ const dataset = await Actor . openDataset ( ) ;
153+ const items = await dataset . getData ( ) ;
154+ expect ( items . items ) . toHaveLength ( 0 ) ;
155+
156+ delete process . env . APIFY_IS_AT_HOME ;
157+ delete process . env . ACTOR_RUN_ID ;
158+ } ) ;
159+
160+ test ( 'should verify charge API call with count=0 vs count>0' , async ( ) => {
161+ // Don't use ACTOR_TEST_PAY_PER_EVENT when simulating Apify platform
162+ delete process . env . ACTOR_TEST_PAY_PER_EVENT ;
163+
164+ process . env . ACTOR_MAX_TOTAL_CHARGE_USD = '10.0' ;
165+ process . env . APIFY_IS_AT_HOME = '1' ;
166+ process . env . APIFY_TOKEN = 'this-wont-work' ;
167+ process . env . ACTOR_RUN_ID = 'test-run-id' ;
168+
169+ // Set pricing info via env vars
170+ process . env . APIFY_ACTOR_PRICING_INFO = JSON . stringify ( {
171+ pricingModel : 'PAY_PER_EVENT' ,
172+ pricingPerEvent : {
173+ actorChargeEvents : {
174+ 'test-event' : {
175+ eventTitle : 'Test Event' ,
176+ eventPriceUsd : 1.0 ,
177+ } ,
178+ } ,
179+ } ,
180+ } ) ;
181+ process . env . APIFY_CHARGED_ACTOR_EVENT_COUNTS = JSON . stringify ( { } ) ;
182+
183+ await Actor . init ( ) ;
184+ localStorageEmulator . reapplyStorageClient ( ) ;
185+
186+ // Mock the API client charge method
187+ const chargeSpy = vitest . fn ( ) . mockResolvedValue ( undefined ) ;
188+ vitest . spyOn ( Actor . apifyClient , 'run' ) . mockReturnValue ( {
189+ charge : chargeSpy ,
190+ } as any ) ;
191+
192+ // Call charge with count=0 - this should NOT call the API
193+ const result1 = await Actor . charge ( {
194+ eventName : 'test-event' ,
195+ count : 0 ,
196+ } ) ;
197+ expect ( chargeSpy ) . not . toHaveBeenCalled ( ) ;
198+ expect ( result1 . chargedCount ) . toBe ( 0 ) ;
199+
200+ // Call charge with count=1 - this SHOULD call the API
201+ const result2 = await Actor . charge ( {
202+ eventName : 'test-event' ,
203+ count : 1 ,
204+ } ) ;
205+ expect ( chargeSpy ) . toHaveBeenCalledTimes ( 1 ) ;
206+ expect ( chargeSpy ) . toHaveBeenCalledWith ( {
207+ eventName : 'test-event' ,
208+ count : 1 ,
209+ } ) ;
210+ expect ( result2 . chargedCount ) . toBe ( 1 ) ;
211+ } ) ;
212+ } ) ;
213+
214+ describe ( 'calculateMaxEventChargeCountWithinLimit()' , ( ) => {
215+ test ( 'should not return negative values when budget is overdrawn' , async ( ) => {
216+ // Don't use ACTOR_TEST_PAY_PER_EVENT when simulating Apify platform
217+ delete process . env . ACTOR_TEST_PAY_PER_EVENT ;
218+
219+ process . env . ACTOR_MAX_TOTAL_CHARGE_USD = '0.00025' ;
220+
221+ // Set pricing info and already charged events that overdraw the budget
222+ process . env . APIFY_ACTOR_PRICING_INFO = JSON . stringify ( {
223+ pricingModel : 'PAY_PER_EVENT' ,
224+ pricingPerEvent : {
225+ actorChargeEvents : {
226+ event : {
227+ eventTitle : 'Event' ,
228+ eventPriceUsd : 0.0003 ,
229+ } ,
230+ 'apify-actor-start' : {
231+ eventTitle : 'Actor start' ,
232+ eventPriceUsd : 0.00005 ,
233+ } ,
234+ } ,
235+ } ,
236+ } ) ;
237+ // Already charged 2 events worth $0.00035, which exceeds the $0.00025 budget
238+ process . env . APIFY_CHARGED_ACTOR_EVENT_COUNTS = JSON . stringify ( {
239+ event : 1 ,
240+ 'apify-actor-start' : 1 ,
241+ } ) ;
242+
243+ await Actor . init ( ) ;
244+
245+ const chargingManager = Actor . getChargingManager ( ) ;
246+ const maxCount =
247+ chargingManager . calculateMaxEventChargeCountWithinLimit (
248+ 'event' ,
249+ ) ;
250+
251+ expect ( maxCount ) . toBe ( 0 ) ;
252+ } ) ;
253+ } ) ;
254+
255+ describe ( 'charge() with overdrawn budget' , ( ) => {
256+ test ( 'should handle charging when budget is already overdrawn' , async ( ) => {
257+ // Don't use ACTOR_TEST_PAY_PER_EVENT when simulating Apify platform
258+ delete process . env . ACTOR_TEST_PAY_PER_EVENT ;
259+
260+ process . env . ACTOR_MAX_TOTAL_CHARGE_USD = '0.00025' ;
261+ process . env . APIFY_IS_AT_HOME = '1' ;
262+ process . env . APIFY_TOKEN = 'this-wont-work' ;
263+ process . env . ACTOR_RUN_ID = 'test-run-id' ;
264+
265+ // Set pricing info
266+ process . env . APIFY_ACTOR_PRICING_INFO = JSON . stringify ( {
267+ pricingModel : 'PAY_PER_EVENT' ,
268+ pricingPerEvent : {
269+ actorChargeEvents : {
270+ event : {
271+ eventTitle : 'Event' ,
272+ eventPriceUsd : 0.0003 ,
273+ } ,
274+ 'apify-actor-start' : {
275+ eventTitle : 'Actor start' ,
276+ eventPriceUsd : 0.00005 ,
277+ } ,
278+ } ,
279+ } ,
280+ } ) ;
281+ // Already charged actor-start, leaving only $0.0002 which is less than the event cost
282+ process . env . APIFY_CHARGED_ACTOR_EVENT_COUNTS = JSON . stringify ( {
283+ event : 0 ,
284+ 'apify-actor-start' : 1 ,
285+ } ) ;
286+
287+ await Actor . init ( ) ;
288+ localStorageEmulator . reapplyStorageClient ( ) ;
289+
290+ // Mock the API client charge method
291+ const chargeSpy = vitest . fn ( ) . mockResolvedValue ( undefined ) ;
292+ vitest . spyOn ( Actor . apifyClient , 'run' ) . mockReturnValue ( {
293+ charge : chargeSpy ,
294+ } as any ) ;
295+
296+ // Try to charge - the budget doesn't allow another event
297+ const chargeResult = await Actor . charge ( {
298+ eventName : 'event' ,
299+ count : 1 ,
300+ } ) ;
301+ expect ( chargeResult . chargedCount ) . toBe ( 0 ) ;
302+
303+ // Try to push data - the budget doesn't allow this either
304+ const pushResult = await Actor . pushData (
305+ [ { hello : 'world' } ] ,
306+ 'event' ,
307+ ) ;
308+ expect ( pushResult ! . chargedCount ) . toBe ( 0 ) ;
309+
310+ // The API should NOT have been called in either case
311+ expect ( chargeSpy ) . not . toHaveBeenCalled ( ) ;
312+ } ) ;
78313 } ) ;
79314} ) ;
0 commit comments