@@ -6,14 +6,16 @@ import {
66 inputValidationMock ,
77 inputValidationMockFns ,
88} from '@sim/testing/mocks/input-validation.mock'
9+ import { getMockLogger } from '@sim/testing/mocks/logger.mock'
910import { uploadsCopilotMock , uploadsCopilotMockFns } from '@sim/testing/mocks/uploads-copilot.mock'
1011import { uploadsExecutionMock } from '@sim/testing/mocks/uploads-execution.mock'
1112import { utilsHelpersMock , utilsHelpersMockFns } from '@sim/testing/mocks/utils-helpers.mock'
1213import { beforeEach , describe , expect , it , vi } from 'vitest'
1314
1415const mocks = vi . hoisted ( ( ) => ( {
1516 fetch : vi . fn ( ) ,
16- getFalAICostMetadata : vi . fn ( ) ,
17+ getFalAIEstimatedCostMetadata : vi . fn ( ) ,
18+ getFalAIBillingReceipt : vi . fn ( ) ,
1719} ) )
1820
1921vi . mock ( '@/lib/core/security/input-validation.server' , ( ) => inputValidationMock )
@@ -23,7 +25,9 @@ vi.mock('@sim/utils/helpers', () => utilsHelpersMock)
2325vi . mock ( '@/lib/core/execution-limits' , ( ) => executionLimitsMock )
2426
2527vi . mock ( '@/lib/tools/falai-pricing' , ( ) => ( {
26- getFalAICostMetadata : mocks . getFalAICostMetadata ,
28+ FALAI_COST_LOOKUP_TIMEOUT_MS : 10_000 ,
29+ getFalAIEstimatedCostMetadata : mocks . getFalAIEstimatedCostMetadata ,
30+ getFalAIBillingReceipt : mocks . getFalAIBillingReceipt ,
2731} ) )
2832
2933vi . mock ( '@/lib/uploads/contexts/copilot' , ( ) => uploadsCopilotMock )
@@ -33,6 +37,7 @@ vi.mock('@/lib/uploads/contexts/execution', () => uploadsExecutionMock)
3337const { mockSecureFetchWithPinnedIP, mockValidateUrlWithDNS } = inputValidationMockFns
3438
3539import { executeImageGeneration } from '@/lib/internal/image/operations'
40+ import { observeServiceCosts } from '@/lib/mothership/billing/service-observer'
3641
3742const { mockInterruptibleSleep } = utilsHelpersMockFns
3843
@@ -50,6 +55,8 @@ const falInput = {
5055describe ( 'image operations' , ( ) => {
5156 beforeEach ( ( ) => {
5257 mocks . fetch . mockReset ( )
58+ mocks . getFalAIEstimatedCostMetadata . mockReset ( )
59+ mocks . getFalAIBillingReceipt . mockReset ( ) . mockResolvedValue ( undefined )
5360 mockSecureFetchWithPinnedIP . mockReset ( )
5461 vi . stubGlobal ( 'fetch' , mocks . fetch )
5562 mockInterruptibleSleep . mockResolvedValue ( undefined )
@@ -182,15 +189,17 @@ describe('image operations', () => {
182189 it ( 'retains the completed fal receipt when delivery is interrupted' , async ( ) => {
183190 const controller = new AbortController ( )
184191 mocks . fetch . mockResolvedValueOnce ( Response . json ( { request_id : 'paid-job' } ) )
185- mocks . getFalAICostMetadata . mockImplementation ( async ( { signal } : { signal ?: AbortSignal } ) => {
186- signal ?. throwIfAborted ( )
187- return {
188- requestId : 'paid-job' ,
189- endpointId : 'fal-ai/nano-banana-2' ,
190- costDollars : 0.1 ,
191- source : 'billing_events' ,
192+ mocks . getFalAIBillingReceipt . mockImplementation (
193+ async ( { signal } : { signal ?: AbortSignal } ) => {
194+ signal ?. throwIfAborted ( )
195+ return {
196+ requestId : 'paid-job' ,
197+ endpointId : 'fal-ai/nano-banana-2' ,
198+ costDollars : 0.1 ,
199+ source : 'billing_events' ,
200+ }
192201 }
193- } )
202+ )
194203 mockSecureFetchWithPinnedIP . mockResolvedValueOnce ( Response . json ( { status : 'COMPLETED' } ) )
195204 mockSecureFetchWithPinnedIP . mockImplementationOnce ( async ( ) => {
196205 controller . abort ( )
@@ -207,9 +216,130 @@ describe('image operations', () => {
207216 }
208217 )
209218 ) . rejects . toMatchObject ( { name : 'AbortError' } )
210- expect ( mocks . getFalAICostMetadata ) . toHaveBeenCalledWith (
219+ expect ( mocks . getFalAIBillingReceipt ) . toHaveBeenCalledWith (
211220 expect . objectContaining ( { requestId : 'paid-job' } )
212221 )
213- expect ( mocks . getFalAICostMetadata . mock . calls [ 0 ] [ 0 ] . signal ) . toBeUndefined ( )
222+ expect ( mocks . getFalAIBillingReceipt . mock . calls [ 0 ] [ 0 ] . signal ) . toBeUndefined ( )
223+ } )
224+ it . each ( [ 422 , 500 ] ) (
225+ 'reports HTTP %i generation failures without speculative delivery receipts' ,
226+ async ( status ) => {
227+ mocks . fetch . mockResolvedValueOnce ( Response . json ( { request_id : 'rejected-job' } ) )
228+ mockSecureFetchWithPinnedIP
229+ . mockResolvedValueOnce ( Response . json ( { status : 'COMPLETED' } ) )
230+ . mockResolvedValueOnce (
231+ Response . json (
232+ {
233+ detail : [
234+ { type : 'no_media_generated' , msg : 'secret-prompt' , input : 'secret-prompt' } ,
235+ ] ,
236+ } ,
237+ { status }
238+ )
239+ )
240+ mocks . getFalAIEstimatedCostMetadata . mockResolvedValue ( {
241+ requestId : 'rejected-job' ,
242+ source : 'fallback_floor' ,
243+ costDollars : 0.05 ,
244+ } )
245+ const receipts : string [ ] = [ ]
246+ const response = await observeServiceCosts (
247+ async ( ) => { } ,
248+ ( ) =>
249+ executeImageGeneration (
250+ { ...falInput , useHostedCostTracking : true } ,
251+ { userId : 'user' , requestId : 'rejected' }
252+ ) ,
253+ async ( receipt ) => {
254+ receipts . push ( receipt )
255+ }
256+ )
257+ expect ( response . status ) . toBe ( status === 422 ? 422 : 502 )
258+ const body = await response . json ( )
259+ expect ( body . error ) . toContain ( 'no_media_generated' )
260+ expect ( JSON . stringify ( body ) ) . not . toContain ( 'secret-prompt' )
261+ expect ( JSON . stringify ( getMockLogger ( 'ImageOperations' ) . error . mock . calls ) ) . not . toContain (
262+ 'secret-prompt'
263+ )
264+ expect ( receipts ) . toEqual ( [ ] )
265+ expect ( mocks . getFalAIEstimatedCostMetadata ) . not . toHaveBeenCalled ( )
266+ expect ( mockUploadCopilotFile ) . not . toHaveBeenCalled ( )
267+ }
268+ )
269+
270+ it . each ( [
271+ { bytes : 32 * 1024 * 1024 , declared : true , expected : 200 } ,
272+ { bytes : 101 * 1024 * 1024 , declared : true , expected : 413 } ,
273+ { bytes : 101 * 1024 * 1024 , declared : false , expected : 413 } ,
274+ ] ) (
275+ 'bounds remote generated images ($bytes bytes, declared=$declared)' ,
276+ async ( { bytes, declared, expected } ) => {
277+ mocks . fetch . mockResolvedValueOnce ( Response . json ( { request_id : 'large-image' } ) )
278+ let remaining = bytes
279+ const chunk = new Uint8Array ( 1024 * 1024 )
280+ const binary = new Response (
281+ new ReadableStream ( {
282+ pull ( controller ) {
283+ if ( remaining <= 0 ) {
284+ controller . close ( )
285+ return
286+ }
287+ controller . enqueue ( chunk )
288+ remaining -= chunk . length
289+ } ,
290+ } ) ,
291+ {
292+ headers : {
293+ 'Content-Type' : 'image/png' ,
294+ ...( declared ? { 'Content-Length' : String ( bytes ) } : { } ) ,
295+ } ,
296+ }
297+ )
298+ mockSecureFetchWithPinnedIP
299+ . mockResolvedValueOnce ( Response . json ( { status : 'COMPLETED' } ) )
300+ . mockResolvedValueOnce (
301+ Response . json ( { images : [ { url : 'https://cdn.example/image.png' } ] } )
302+ )
303+ . mockResolvedValueOnce ( binary )
304+ const response = await executeImageGeneration ( falInput , {
305+ userId : 'user' ,
306+ requestId : 'large' ,
307+ } )
308+ expect ( response . status ) . toBe ( expected )
309+ if ( expected === 200 ) {
310+ expect ( mockUploadCopilotFile . mock . calls [ 0 ] [ 0 ] . buffer . byteLength ) . toBe ( bytes )
311+ } else {
312+ expect ( mockUploadCopilotFile ) . not . toHaveBeenCalled ( )
313+ }
314+ }
315+ )
316+ it ( 'reconciles exact image receipts when a completed queue status contains an error' , async ( ) => {
317+ mocks . fetch . mockResolvedValueOnce ( Response . json ( { request_id : 'image-queue-error' } ) )
318+ mockSecureFetchWithPinnedIP . mockResolvedValueOnce (
319+ Response . json ( { status : 'COMPLETED' , error : 'sensitive provider error' } )
320+ )
321+ mocks . getFalAIBillingReceipt . mockResolvedValueOnce ( {
322+ requestId : 'image-queue-error' ,
323+ endpointId : 'fal-ai/nano-banana-2' ,
324+ source : 'billing_events' ,
325+ costDollars : 0.1 ,
326+ } )
327+ const receipts : string [ ] = [ ]
328+ const response = await observeServiceCosts (
329+ async ( ) => { } ,
330+ ( ) =>
331+ executeImageGeneration (
332+ { ...falInput , useHostedCostTracking : true } ,
333+ { userId : 'user' , requestId : 'queue-error' }
334+ ) ,
335+ async ( receipt ) => {
336+ receipts . push ( receipt )
337+ }
338+ )
339+ expect ( response . ok ) . toBe ( false )
340+ expect ( JSON . stringify ( await response . json ( ) ) ) . not . toContain ( 'sensitive provider error' )
341+ expect ( receipts ) . toHaveLength ( 1 )
342+ expect ( receipts [ 0 ] ) . toContain ( '0.1 (billing_events)' )
343+ expect ( mockSecureFetchWithPinnedIP ) . toHaveBeenCalledTimes ( 1 )
214344 } )
215345} )
0 commit comments