Skip to content

Commit 0ab7a1d

Browse files
committed
fix: address PR review feedback
- Fix grantCreditOperation transaction bug: use dbClient instead of db when tx is provided to maintain transaction boundary - Remove placeholder test and replace with real assertion - Add DI support to revokeGrantByOperationId so tests can call the actual function with injected transaction - Update revokeGrantByOperationId tests to call real function with DI - Add TODO comments for consumeOrganizationCredits and grantOrganizationCredits noting they need integration tests
1 parent 8a789ec commit 0ab7a1d

File tree

3 files changed

+143
-133
lines changed

3 files changed

+143
-133
lines changed

packages/billing/src/__tests__/grant-credits.test.ts

Lines changed: 125 additions & 129 deletions
Original file line numberDiff line numberDiff line change
@@ -289,18 +289,30 @@ describe('grant-credits', () => {
289289
where: () => Promise.resolve(),
290290
}),
291291
}),
292-
// Note: grantCreditOperation uses `db.insert` directly when no debt,
293-
// not `tx.insert`. This test verifies the debt-checking path works.
292+
insert: () => ({
293+
values: (values: any) => {
294+
insertedGrants.push(values)
295+
return Promise.resolve()
296+
},
297+
}),
294298
}
295299

296-
// Since the function uses db.insert directly for no-debt case,
297-
// we test by verifying that the debt check path returns empty array
298-
// and the function completes without error
299-
// The actual insert would hit the real DB, so we verify the flow instead
300-
301-
// For a pure unit test, we'd need to inject the db dependency too
302-
// This is a limitation of the current DI pattern
303-
expect(true).toBe(true) // Placeholder - function flow verified
300+
await grantCreditOperation({
301+
userId: 'user-123',
302+
amount: 500,
303+
type: 'free',
304+
description: 'Monthly free credits',
305+
expiresAt: futureDate,
306+
operationId: 'new-grant-1',
307+
tx: mockTx as any,
308+
logger,
309+
})
310+
311+
// Should have created a new grant with full balance (no debt to deduct)
312+
expect(insertedGrants.length).toBe(1)
313+
expect(insertedGrants[0].principal).toBe(500)
314+
expect(insertedGrants[0].balance).toBe(500)
315+
expect(insertedGrants[0].description).toBe('Monthly free credits')
304316
})
305317

306318
it('should not create grant when debt exceeds amount', async () => {
@@ -368,165 +380,149 @@ describe('grant-credits', () => {
368380
describe('revokeGrantByOperationId', () => {
369381
it('should successfully revoke a grant with positive balance', async () => {
370382
const updatedValues: any[] = []
371-
let transactionCalled = false
372-
373-
// Mock db.transaction
374-
const mockDb = {
375-
transaction: async <T>(callback: (tx: any) => Promise<T>): Promise<T> => {
376-
transactionCalled = true
377-
const tx = {
378-
query: {
379-
creditLedger: {
380-
findFirst: async () => ({
381-
operation_id: 'grant-to-revoke',
382-
user_id: 'user-123',
383-
principal: 500,
384-
balance: 300, // 200 already consumed
385-
type: 'purchase',
386-
description: 'Purchased 500 credits',
387-
}),
388-
},
389-
},
390-
update: () => ({
391-
set: (values: any) => ({
392-
where: () => {
393-
updatedValues.push(values)
394-
return Promise.resolve()
395-
},
383+
384+
const mockTransaction: BillingTransactionFn = async (callback) => {
385+
const tx = {
386+
query: {
387+
creditLedger: {
388+
findFirst: async () => ({
389+
operation_id: 'grant-to-revoke',
390+
user_id: 'user-123',
391+
principal: 500,
392+
balance: 300, // 200 already consumed
393+
type: 'purchase',
394+
description: 'Purchased 500 credits',
396395
}),
396+
},
397+
},
398+
update: () => ({
399+
set: (values: any) => ({
400+
where: () => {
401+
updatedValues.push(values)
402+
return Promise.resolve()
403+
},
397404
}),
398-
}
399-
return callback(tx)
400-
},
405+
}),
406+
}
407+
return callback(tx)
401408
}
402409

403-
// Use the actual function with mocked db
404-
const result = await mockDb.transaction(async (tx) => {
405-
const grant = await tx.query.creditLedger.findFirst({})
406-
if (!grant) return false
407-
if (grant.balance < 0) return false
408-
409-
await tx
410-
.update()
411-
.set({
412-
principal: 0,
413-
balance: 0,
414-
description: `${grant.description} (Revoked: Test refund)`,
415-
})
416-
.where()
417-
418-
return true
410+
const result = await revokeGrantByOperationId({
411+
operationId: 'grant-to-revoke',
412+
reason: 'Test refund',
413+
logger,
414+
deps: { transaction: mockTransaction },
419415
})
420416

421417
expect(result).toBe(true)
422-
expect(transactionCalled).toBe(true)
423418
expect(updatedValues.length).toBe(1)
424419
expect(updatedValues[0].principal).toBe(0)
425420
expect(updatedValues[0].balance).toBe(0)
426421
expect(updatedValues[0].description).toContain('Revoked: Test refund')
427422
})
428423

429424
it('should return false when grant does not exist', async () => {
430-
const mockDb = {
431-
transaction: async <T>(callback: (tx: any) => Promise<T>): Promise<T> => {
432-
const tx = {
433-
query: {
434-
creditLedger: {
435-
findFirst: async () => null, // Grant not found
436-
},
425+
const mockTransaction: BillingTransactionFn = async (callback) => {
426+
const tx = {
427+
query: {
428+
creditLedger: {
429+
findFirst: async () => null, // Grant not found
437430
},
438-
update: () => ({
439-
set: () => ({
440-
where: () => Promise.resolve(),
441-
}),
431+
},
432+
update: () => ({
433+
set: () => ({
434+
where: () => Promise.resolve(),
442435
}),
443-
}
444-
return callback(tx)
445-
},
436+
}),
437+
}
438+
return callback(tx)
446439
}
447440

448-
const result = await mockDb.transaction(async (tx) => {
449-
const grant = await tx.query.creditLedger.findFirst({})
450-
if (!grant) return false
451-
return true
441+
const result = await revokeGrantByOperationId({
442+
operationId: 'non-existent-grant',
443+
reason: 'Test refund',
444+
logger,
445+
deps: { transaction: mockTransaction },
452446
})
453447

454448
expect(result).toBe(false)
455449
})
456450

457451
it('should return false when grant has negative balance', async () => {
458-
const mockDb = {
459-
transaction: async <T>(callback: (tx: any) => Promise<T>): Promise<T> => {
460-
const tx = {
461-
query: {
462-
creditLedger: {
463-
findFirst: async () => ({
464-
operation_id: 'debt-grant',
465-
user_id: 'user-123',
466-
principal: 500,
467-
balance: -100, // User has overspent
468-
type: 'free',
469-
description: 'Monthly free credits',
470-
}),
471-
},
472-
},
473-
update: () => ({
474-
set: () => ({
475-
where: () => Promise.resolve(),
452+
const mockTransaction: BillingTransactionFn = async (callback) => {
453+
const tx = {
454+
query: {
455+
creditLedger: {
456+
findFirst: async () => ({
457+
operation_id: 'debt-grant',
458+
user_id: 'user-123',
459+
principal: 500,
460+
balance: -100, // User has overspent
461+
type: 'free',
462+
description: 'Monthly free credits',
476463
}),
464+
},
465+
},
466+
update: () => ({
467+
set: () => ({
468+
where: () => Promise.resolve(),
477469
}),
478-
}
479-
return callback(tx)
480-
},
470+
}),
471+
}
472+
return callback(tx)
481473
}
482474

483-
const result = await mockDb.transaction(async (tx) => {
484-
const grant = await tx.query.creditLedger.findFirst({})
485-
if (!grant) return false
486-
if (grant.balance < 0) return false // Cannot revoke debt
487-
return true
475+
const result = await revokeGrantByOperationId({
476+
operationId: 'debt-grant',
477+
reason: 'Test refund',
478+
logger,
479+
deps: { transaction: mockTransaction },
488480
})
489481

490482
expect(result).toBe(false)
491483
})
492484

493-
it('should return false when grant balance is exactly zero', async () => {
494-
const mockDb = {
495-
transaction: async <T>(callback: (tx: any) => Promise<T>): Promise<T> => {
496-
const tx = {
497-
query: {
498-
creditLedger: {
499-
findFirst: async () => ({
500-
operation_id: 'depleted-grant',
501-
user_id: 'user-123',
502-
principal: 500,
503-
balance: 0, // Fully consumed
504-
type: 'free',
505-
description: 'Monthly free credits',
506-
}),
507-
},
508-
},
509-
update: () => ({
510-
set: (values: any) => ({
511-
where: () => Promise.resolve(),
485+
it('should successfully revoke a grant with zero balance', async () => {
486+
const updatedValues: any[] = []
487+
488+
const mockTransaction: BillingTransactionFn = async (callback) => {
489+
const tx = {
490+
query: {
491+
creditLedger: {
492+
findFirst: async () => ({
493+
operation_id: 'depleted-grant',
494+
user_id: 'user-123',
495+
principal: 500,
496+
balance: 0, // Fully consumed
497+
type: 'free',
498+
description: 'Monthly free credits',
512499
}),
500+
},
501+
},
502+
update: () => ({
503+
set: (values: any) => ({
504+
where: () => {
505+
updatedValues.push(values)
506+
return Promise.resolve()
507+
},
513508
}),
514-
}
515-
return callback(tx)
516-
},
509+
}),
510+
}
511+
return callback(tx)
517512
}
518513

519-
// Note: The actual revokeGrantByOperationId checks for balance < 0,
520-
// so a balance of 0 would still be revoked (nothing to revoke though)
521-
const result = await mockDb.transaction(async (tx) => {
522-
const grant = await tx.query.creditLedger.findFirst({})
523-
if (!grant) return false
524-
if (grant.balance < 0) return false
525-
// Balance of 0 is technically revocable but there's nothing to revoke
526-
return true
514+
// Balance of 0 is not negative, so it can be revoked (nothing to actually revoke though)
515+
const result = await revokeGrantByOperationId({
516+
operationId: 'depleted-grant',
517+
reason: 'Test refund',
518+
logger,
519+
deps: { transaction: mockTransaction },
527520
})
528521

529522
expect(result).toBe(true)
523+
expect(updatedValues.length).toBe(1)
524+
expect(updatedValues[0].principal).toBe(0)
525+
expect(updatedValues[0].balance).toBe(0)
530526
})
531527
})
532528
})

packages/billing/src/grant-credits.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,10 @@ import { and, desc, eq, gt, isNull, lte, or, sql } from 'drizzle-orm'
1212
import { generateOperationIdTimestamp } from './utils'
1313

1414
import type { Logger } from '@codebuff/common/types/contracts/logger'
15-
import type { TriggerMonthlyResetAndGrantDeps } from '@codebuff/common/types/contracts/billing'
15+
import type {
16+
TriggerMonthlyResetAndGrantDeps,
17+
BillingTransactionFn,
18+
} from '@codebuff/common/types/contracts/billing'
1619
import type { GrantType } from '@codebuff/internal/db/schema'
1720

1821
type CreditGrantSelect = typeof schema.creditLedger.$inferSelect
@@ -201,7 +204,7 @@ export async function grantCreditOperation(params: {
201204
} else {
202205
// No debt - create grant normally
203206
try {
204-
await db.insert(schema.creditLedger).values({
207+
await dbClient.insert(schema.creditLedger).values({
205208
operation_id: operationId,
206209
user_id: userId,
207210
principal: amount,
@@ -290,16 +293,19 @@ export async function processAndGrantCredit(params: {
290293
*
291294
* @param operationId The operation ID of the grant to revoke
292295
* @param reason The reason for revoking the credits (e.g. refund)
296+
* @param deps Optional dependencies for testing (transaction function)
293297
* @returns true if the grant was found and revoked, false otherwise
294298
*/
295299
export async function revokeGrantByOperationId(params: {
296300
operationId: string
297301
reason: string
298302
logger: Logger
303+
deps?: { transaction?: BillingTransactionFn }
299304
}): Promise<boolean> {
300-
const { operationId, reason, logger } = params
305+
const { operationId, reason, logger, deps = {} } = params
306+
const transaction = deps.transaction ?? db.transaction.bind(db)
301307

302-
return await db.transaction(async (tx) => {
308+
return await transaction(async (tx) => {
303309
const grant = await tx.query.creditLedger.findFirst({
304310
where: eq(schema.creditLedger.operation_id, operationId),
305311
})

packages/billing/src/org-billing.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -266,6 +266,10 @@ export async function calculateOrganizationUsageAndBalance(
266266

267267
/**
268268
* Consumes credits from organization grants in priority order.
269+
*
270+
* TODO: Add DI support for testing. This function requires more complex mocking
271+
* due to withSerializableTransaction usage. Better tested with integration tests
272+
* that verify the full org credit flow works correctly.
269273
*/
270274
export async function consumeOrganizationCredits(params: {
271275
organizationId: string
@@ -308,6 +312,10 @@ export async function consumeOrganizationCredits(params: {
308312

309313
/**
310314
* Grants credits to an organization.
315+
*
316+
* TODO: Add DI support for testing. This function uses the global db directly.
317+
* Better tested with integration tests that verify the full org credit flow
318+
* works correctly with database constraints and idempotency checks.
311319
*/
312320
export async function grantOrganizationCredits(
313321
params: OptionalFields<

0 commit comments

Comments
 (0)