Skip to content

Commit bd29733

Browse files
Merge pull request #808 from autonomys/feat/intent-requested-bytes
feat(intents): reject over-cap purchases before payment
2 parents 12ab065 + 6c95c13 commit bd29733

14 files changed

Lines changed: 1041 additions & 52 deletions

File tree

apps/backend/__tests__/unit/repositories/intents.spec.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,4 +81,5 @@ describe('Intents Repository — payment fields', () => {
8181
expect(updated?.quotedTokenAmount).toBe(1_000_000n)
8282
expect(updated?.usdRateAtCreation).toBe(6_400_000_000_000_000n)
8383
})
84+
8485
})

apps/backend/__tests__/unit/useCases/intents.spec.ts

Lines changed: 300 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,27 @@
11
import { jest } from '@jest/globals'
22
import { IntentsUseCases } from '../../../src/core/users/intents.js'
33
import { intentsRepository } from '../../../src/infrastructure/repositories/users/intents.js'
4+
import { purchasedCreditsRepository } from '../../../src/infrastructure/repositories/users/purchasedCredits.js'
45
import { EventRouter } from '../../../src/infrastructure/eventRouter/index.js'
56
import { 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'
825
import { ok, err } from 'neverthrow'
926

1027
describe('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

Comments
 (0)