11import { jest } from '@jest/globals'
22import { IntentsUseCases } from '../../../src/core/users/intents.js'
33import { intentsRepository } from '../../../src/infrastructure/repositories/users/intents.js'
4+ import { purchasedCreditsRepository } from '../../../src/infrastructure/repositories/users/purchasedCredits.js'
45import { EventRouter } from '../../../src/infrastructure/eventRouter/index.js'
56import { AccountsUseCases } from '../../../src/core/users/accounts.js'
6- import { ConflictError , ForbiddenError , GoneError } from '../../../src/errors/index.js'
7- import { IntentStatus , UserRole , type Intent , type User } from '@auto-drive/models'
7+ import { config } from '../../../src/config.js'
8+ import {
9+ BadRequestError ,
10+ ConflictError ,
11+ CreditCapExceededError ,
12+ ForbiddenError ,
13+ GoneError ,
14+ } from '../../../src/errors/index.js'
15+ import {
16+ IntentStatus ,
17+ PaymentMethod ,
18+ UserRole ,
19+ type Account ,
20+ type Intent ,
21+ type PurchasedCreditSummary ,
22+ type User ,
23+ type UserWithOrganization ,
24+ } from '@auto-drive/models'
825import { ok , err } from 'neverthrow'
926
1027describe ( 'IntentsUseCases' , ( ) => {
@@ -16,8 +33,31 @@ describe('IntentsUseCases', () => {
1633 createdAt : now ,
1734 updatedAt : now ,
1835 authProvider : 'github' ,
36+ organizationId : 'org-1' ,
1937 } as unknown as User
2038
39+ // createIntent needs the organization to resolve an account for the cap
40+ // pre-check; handleAuth already hands the controller this shape.
41+ const orgUser = user as unknown as UserWithOrganization
42+
43+ const cap = config . credits . maxBytesPerUser
44+
45+ // Point the cap pre-check at a given already-purchased balance.
46+ const mockPurchasedBalance = ( uploadBytesRemaining : bigint ) => {
47+ jest
48+ . spyOn ( AccountsUseCases , 'getOrCreateAccount' )
49+ . mockResolvedValue ( { id : 'acc-1' } as unknown as Account )
50+ return jest
51+ . spyOn ( purchasedCreditsRepository , 'getRemainingCredits' )
52+ . mockResolvedValue ( {
53+ uploadBytesRemaining,
54+ uploadBytesOriginal : uploadBytesRemaining ,
55+ downloadBytesRemaining : 0n ,
56+ nextExpiryDate : null ,
57+ activeRowCount : 1 ,
58+ } as PurchasedCreditSummary )
59+ }
60+
2161 beforeEach ( ( ) => {
2262 jest . clearAllMocks ( )
2363 jest . spyOn ( IntentsUseCases , 'getPrice' ) . mockResolvedValue ( { price : 1 , pricePerGB : 1073741824 } )
@@ -36,8 +76,10 @@ describe('IntentsUseCases', () => {
3676 . spyOn ( intentsRepository , 'createIntent' )
3777 . mockImplementation ( async ( intent ) => intent )
3878
39- const intent = await IntentsUseCases . createIntent ( user )
79+ const result = await IntentsUseCases . createIntent ( orgUser )
4080
81+ expect ( result . isOk ( ) ) . toBe ( true )
82+ const intent = result . _unsafeUnwrap ( )
4183 expect ( intent . userPublicId ) . toBe ( user . publicId )
4284 expect ( intent . status ) . toBe ( IntentStatus . PENDING )
4385 expect ( intent . shannonsPerByte ) . toBe ( 1n )
@@ -49,9 +91,10 @@ describe('IntentsUseCases', () => {
4991 . mockImplementation ( async ( intent ) => intent )
5092
5193 const before = new Date ( )
52- const intent = await IntentsUseCases . createIntent ( user )
94+ const result = await IntentsUseCases . createIntent ( orgUser )
5395 const after = new Date ( )
5496
97+ const intent = result . _unsafeUnwrap ( )
5598 expect ( intent . expiresAt ) . toBeDefined ( )
5699 expect ( intent . expiresAt ! . getTime ( ) ) . toBeGreaterThan ( before . getTime ( ) )
57100 // expiresAt should be at least 1 minute ahead (config default is 10 min)
@@ -63,6 +106,259 @@ describe('IntentsUseCases', () => {
63106 )
64107 } )
65108
109+ // ────────────────────────────────────────────────────────────────────────────
110+ // createIntent — requestedBytes
111+ //
112+ // The regression that matters most in this group is the first test: the live
113+ // frontend posts no body, and that path must stay byte-for-byte what it was.
114+ // ────────────────────────────────────────────────────────────────────────────
115+
116+ it ( 'createIntent without requestedBytes runs no cap pre-check' , async ( ) => {
117+ jest
118+ . spyOn ( intentsRepository , 'createIntent' )
119+ . mockImplementation ( async ( intent ) => intent )
120+ const accountSpy = jest . spyOn ( AccountsUseCases , 'getOrCreateAccount' )
121+ const balanceSpy = jest . spyOn (
122+ purchasedCreditsRepository ,
123+ 'getRemainingCredits' ,
124+ )
125+
126+ const result = await IntentsUseCases . createIntent ( orgUser )
127+
128+ expect ( result . isOk ( ) ) . toBe ( true )
129+ // No size given means nothing to check — the balance must not even be read.
130+ expect ( accountSpy ) . not . toHaveBeenCalled ( )
131+ expect ( balanceSpy ) . not . toHaveBeenCalled ( )
132+ } )
133+
134+ it ( 'createIntent does not persist requestedBytes on the intent' , async ( ) => {
135+ mockPurchasedBalance ( 0n )
136+ const createSpy = jest
137+ . spyOn ( intentsRepository , 'createIntent' )
138+ . mockImplementation ( async ( intent ) => intent )
139+
140+ const result = await IntentsUseCases . createIntent ( orgUser , 1_073_741_824n )
141+
142+ expect ( result . isOk ( ) ) . toBe ( true )
143+ // The size gates creation and is then discarded. Persisting it would store a
144+ // number that reads like a balance and never agrees with one, since credits
145+ // come from paymentAmount / shannonsPerByte.
146+ //
147+ // The whole row is asserted, deliberately. The obvious spelling — checking
148+ // that no key is named after the size — cannot fail: `Intent` has no size
149+ // field, so TypeScript's excess-property check already rejects adding one
150+ // to this object literal. What the compiler cannot catch is the size
151+ // reaching the row under a field that DOES exist (`paymentAmount:
152+ // requestedBytes`), and a value-based check catches that but only while no
153+ // legitimate field happens to hold the same number — it would start failing
154+ // spuriously the moment the mocked price became realistic.
155+ //
156+ // Pinning every field has neither weakness, and adds one the others lack:
157+ // it fails when the row grows a field this test has not considered, which
158+ // is exactly when someone should look at it again.
159+ const created = createSpy . mock . calls [ 0 ] [ 0 ]
160+ expect ( created ) . toStrictEqual ( {
161+ id : expect . any ( String ) ,
162+ userPublicId : user . publicId ,
163+ status : IntentStatus . PENDING ,
164+ paymentMethod : PaymentMethod . AI3_NATIVE ,
165+ paymentAmount : undefined ,
166+ shannonsPerByte : 1n ,
167+ expiresAt : expect . any ( Date ) ,
168+ } )
169+ } )
170+
171+ it . each < [ string , bigint ] > ( [
172+ [ 'zero' , 0n ] ,
173+ [ 'negative' , - 1n ] ,
174+ ] ) (
175+ 'createIntent rejects a %s requestedBytes without pricing or reading the balance' ,
176+ async ( _label , requestedBytes ) => {
177+ const priceSpy = jest . spyOn ( IntentsUseCases , 'getPrice' )
178+ const balanceSpy = jest . spyOn (
179+ purchasedCreditsRepository ,
180+ 'getRemainingCredits' ,
181+ )
182+ const createSpy = jest . spyOn ( intentsRepository , 'createIntent' )
183+
184+ const result = await IntentsUseCases . createIntent ( orgUser , requestedBytes )
185+
186+ expect ( result . isErr ( ) ) . toBe ( true )
187+ expect ( result . _unsafeUnwrapErr ( ) ) . toBeInstanceOf ( BadRequestError )
188+ expect ( priceSpy ) . not . toHaveBeenCalled ( )
189+ expect ( balanceSpy ) . not . toHaveBeenCalled ( )
190+ expect ( createSpy ) . not . toHaveBeenCalled ( )
191+ } ,
192+ )
193+
194+ it ( 'createIntent rejects a requestedBytes above the per-user cap as a bad request' , async ( ) => {
195+ const priceSpy = jest . spyOn ( IntentsUseCases , 'getPrice' )
196+ const balanceSpy = jest . spyOn (
197+ purchasedCreditsRepository ,
198+ 'getRemainingCredits' ,
199+ )
200+
201+ const result = await IntentsUseCases . createIntent ( orgUser , cap + 1n )
202+
203+ expect ( result . isErr ( ) ) . toBe ( true )
204+ // A size that can never fit is malformed, not a headroom problem — and it
205+ // must not cost a balance read to find out.
206+ expect ( result . _unsafeUnwrapErr ( ) ) . toBeInstanceOf ( BadRequestError )
207+ expect ( result . _unsafeUnwrapErr ( ) ) . not . toBeInstanceOf ( CreditCapExceededError )
208+ expect ( balanceSpy ) . not . toHaveBeenCalled ( )
209+ expect ( priceSpy ) . not . toHaveBeenCalled ( )
210+ } )
211+
212+ it ( 'createIntent rejects with CREDIT_CAP_EXCEEDED when the existing balance leaves no room' , async ( ) => {
213+ mockPurchasedBalance ( cap - 100n )
214+ const priceSpy = jest . spyOn ( IntentsUseCases , 'getPrice' )
215+ const createSpy = jest . spyOn ( intentsRepository , 'createIntent' )
216+
217+ const result = await IntentsUseCases . createIntent ( orgUser , 101n )
218+
219+ expect ( result . isErr ( ) ) . toBe ( true )
220+ const error = result . _unsafeUnwrapErr ( )
221+ expect ( error ) . toBeInstanceOf ( CreditCapExceededError )
222+ expect ( error ) . toBeInstanceOf ( ForbiddenError )
223+ // The message has to tell a caller how much room is actually left.
224+ expect ( error . message ) . toContain ( cap . toString ( ) )
225+ expect ( error . message ) . toContain ( ( cap - 100n ) . toString ( ) )
226+ // Rejected before pricing, and before any intent row exists.
227+ expect ( priceSpy ) . not . toHaveBeenCalled ( )
228+ expect ( createSpy ) . not . toHaveBeenCalled ( )
229+ } )
230+
231+ it ( 'createIntent accepts a purchase that lands exactly on the cap' , async ( ) => {
232+ mockPurchasedBalance ( cap - 100n )
233+ jest
234+ . spyOn ( intentsRepository , 'createIntent' )
235+ . mockImplementation ( async ( intent ) => intent )
236+
237+ const result = await IntentsUseCases . createIntent ( orgUser , 100n )
238+
239+ // Boundary must match the authoritative check in
240+ // createPurchasedCreditWithCapCheck, which uses `>`. A stricter pre-check
241+ // here would refuse purchases the real check would have granted.
242+ expect ( result . isOk ( ) ) . toBe ( true )
243+ } )
244+
245+ // ────────────────────────────────────────────────────────────────────────────
246+ // createIntent — the size is required on the USDC path
247+ //
248+ // The asymmetry is the whole gate: optional on AI3 so the documented API-key
249+ // flow keeps working, mandatory on USDC because that path cannot name an
250+ // amount to charge without it. These pin both halves.
251+ // ────────────────────────────────────────────────────────────────────────────
252+
253+ it ( 'createIntent refuses a USDC intent with no requestedBytes' , async ( ) => {
254+ const priceSpy = jest . spyOn ( IntentsUseCases , 'getPrice' )
255+ const createSpy = jest . spyOn ( intentsRepository , 'createIntent' )
256+
257+ const result = await IntentsUseCases . createIntent (
258+ orgUser ,
259+ undefined ,
260+ PaymentMethod . USDC_ETH ,
261+ )
262+
263+ expect ( result . isErr ( ) ) . toBe ( true )
264+ expect ( result . _unsafeUnwrapErr ( ) ) . toBeInstanceOf ( BadRequestError )
265+ // Refused before pricing and before any row exists — a USDC intent without
266+ // a size is unquotable, not merely incomplete.
267+ expect ( priceSpy ) . not . toHaveBeenCalled ( )
268+ expect ( createSpy ) . not . toHaveBeenCalled ( )
269+ } )
270+
271+ it ( 'createIntent still allows an AI3 intent with no requestedBytes' , async ( ) => {
272+ jest
273+ . spyOn ( intentsRepository , 'createIntent' )
274+ . mockImplementation ( async ( intent ) => intent )
275+
276+ // The explicit default, spelled out: making the size mandatory on USDC must
277+ // not make it mandatory on the path third-party API keys already call.
278+ const result = await IntentsUseCases . createIntent (
279+ orgUser ,
280+ undefined ,
281+ PaymentMethod . AI3_NATIVE ,
282+ )
283+
284+ expect ( result . isOk ( ) ) . toBe ( true )
285+ } )
286+
287+ it ( 'createIntent creates a USDC intent when a size is supplied' , async ( ) => {
288+ mockPurchasedBalance ( 0n )
289+ const createSpy = jest
290+ . spyOn ( intentsRepository , 'createIntent' )
291+ . mockImplementation ( async ( intent ) => intent )
292+
293+ const result = await IntentsUseCases . createIntent (
294+ orgUser ,
295+ 1_073_741_824n ,
296+ PaymentMethod . USDC_ETH ,
297+ )
298+
299+ expect ( result . isOk ( ) ) . toBe ( true )
300+ // The method has to reach the row: it is what the payment manager routes on.
301+ expect ( createSpy . mock . calls [ 0 ] [ 0 ] . paymentMethod ) . toBe ( PaymentMethod . USDC_ETH )
302+ } )
303+
304+ it ( 'createIntent defaults an unspecified payment method to AI3' , async ( ) => {
305+ const createSpy = jest
306+ . spyOn ( intentsRepository , 'createIntent' )
307+ . mockImplementation ( async ( intent ) => intent )
308+
309+ const result = await IntentsUseCases . createIntent ( orgUser )
310+
311+ expect ( result . isOk ( ) ) . toBe ( true )
312+ expect ( createSpy . mock . calls [ 0 ] [ 0 ] . paymentMethod ) . toBe (
313+ PaymentMethod . AI3_NATIVE ,
314+ )
315+ } )
316+
317+ // ────────────────────────────────────────────────────────────────────────────
318+ // parseRequestedBytes
319+ // ────────────────────────────────────────────────────────────────────────────
320+
321+ it . each < [ string , unknown ] > ( [
322+ [ 'undefined' , undefined ] ,
323+ [ 'null' , null ] ,
324+ ] ) ( 'parseRequestedBytes treats %s as no size given' , ( _label , raw ) => {
325+ const result = IntentsUseCases . parseRequestedBytes ( raw )
326+ expect ( result . isOk ( ) ) . toBe ( true )
327+ expect ( result . _unsafeUnwrap ( ) ) . toBeUndefined ( )
328+ } )
329+
330+ it . each < [ string , unknown , bigint ] > ( [
331+ [ 'a decimal string' , '1073741824' , 1_073_741_824n ] ,
332+ [ 'a zero string' , '0' , 0n ] ,
333+ [ 'a safe-integer number' , 1_073_741_824 , 1_073_741_824n ] ,
334+ [ 'a bigint' , 1_073_741_824n , 1_073_741_824n ] ,
335+ ] ) ( 'parseRequestedBytes accepts %s' , ( _label , raw , expected ) => {
336+ const result = IntentsUseCases . parseRequestedBytes ( raw )
337+ expect ( result . isOk ( ) ) . toBe ( true )
338+ expect ( result . _unsafeUnwrap ( ) ) . toBe ( expected )
339+ } )
340+
341+ it . each < [ string , unknown ] > ( [
342+ [ 'a fractional string' , '1.5' ] ,
343+ [ 'a fractional number' , 1.5 ] ,
344+ [ 'exponential notation' , '1e9' ] ,
345+ [ 'a hex string' , '0x10' ] ,
346+ [ 'an empty string' , '' ] ,
347+ [ 'whitespace' , ' 10 ' ] ,
348+ [ 'a signed string' , '+10' ] ,
349+ [ 'a non-numeric string' , 'lots' ] ,
350+ [ 'NaN' , Number . NaN ] ,
351+ [ 'Infinity' , Number . POSITIVE_INFINITY ] ,
352+ [ 'a number beyond safe-integer range' , 2 ** 53 ] ,
353+ [ 'a boolean' , true ] ,
354+ [ 'an object' , { bytes : 10 } ] ,
355+ [ 'an array' , [ '10' ] ] ,
356+ ] ) ( 'parseRequestedBytes rejects %s' , ( _label , raw ) => {
357+ const result = IntentsUseCases . parseRequestedBytes ( raw )
358+ expect ( result . isErr ( ) ) . toBe ( true )
359+ expect ( result . _unsafeUnwrapErr ( ) ) . toBeInstanceOf ( BadRequestError )
360+ } )
361+
66362 // ────────────────────────────────────────────────────────────────────────────
67363 // getIntent
68364 // ────────────────────────────────────────────────────────────────────────────
0 commit comments