11/**
22 * Tests for getUserUsageLimit.
33 *
4- * Org-scoped members carry a null `currentUsageLimit` by design, so a user
5- * whose subscription stops being org-scoped without a resync is left null.
6- * The limit read must self-heal that state to the plan/free base plus prepaid
7- * balance instead of failing closed and blocking every execution.
4+ * Legacy membership syncs may leave a null personal usage limit. The limit
5+ * read must recover the plan/free base plus prepaid balance, and subsequent
6+ * subscription syncs must preserve independent personal and organization pools.
87 *
98 * @vitest -environment node
109 */
@@ -25,12 +24,14 @@ afterAll(() => {
2524const {
2625 mockGetFreeTierLimit,
2726 mockGetHighestPrioritySubscription,
27+ mockGetHighestPriorityPersonalSubscription,
2828 mockGetPerUserMinimumLimit,
2929 mockHasPaidSubscriptionStatus,
3030 mockIsOrgScopedSubscription,
3131} = vi . hoisted ( ( ) => ( {
3232 mockGetFreeTierLimit : vi . fn ( ) ,
3333 mockGetHighestPrioritySubscription : vi . fn ( ) ,
34+ mockGetHighestPriorityPersonalSubscription : vi . fn ( ) ,
3435 mockGetPerUserMinimumLimit : vi . fn ( ) ,
3536 mockHasPaidSubscriptionStatus : vi . fn ( ) ,
3637 mockIsOrgScopedSubscription : vi . fn ( ) ,
@@ -48,6 +49,7 @@ vi.mock('@/lib/billing/subscriptions/utils', () => ({
4849
4950vi . mock ( '@/lib/billing/core/plan' , ( ) => ( {
5051 getHighestPrioritySubscription : mockGetHighestPrioritySubscription ,
52+ getHighestPriorityPersonalSubscription : mockGetHighestPriorityPersonalSubscription ,
5153} ) )
5254
5355vi . mock ( '@/lib/billing/core/access' , ( ) => ( {
@@ -205,10 +207,49 @@ describe('syncUsageLimitsFromSubscription', () => {
205207 vi . clearAllMocks ( )
206208 resetDbChainMock ( )
207209 mockIsOrgScopedSubscription . mockReturnValue ( false )
210+ mockHasPaidSubscriptionStatus . mockImplementation ( ( status : string ) => status === 'active' )
211+ } )
212+
213+ it . each ( [
214+ { plan : 'pro' , minimum : 40 } ,
215+ { plan : 'enterprise' , minimum : 0 } ,
216+ ] ) (
217+ 'preserves a personal $plan cap when the user also belongs to an enterprise organization' ,
218+ async ( { plan, minimum } ) => {
219+ const personalSubscription = { plan, referenceId : 'user-1' , status : 'active' }
220+ mockGetHighestPriorityPersonalSubscription . mockResolvedValue ( personalSubscription )
221+ mockGetHighestPrioritySubscription . mockResolvedValue ( {
222+ plan : 'enterprise' ,
223+ referenceId : 'org-1' ,
224+ status : 'active' ,
225+ } )
226+ mockIsOrgScopedSubscription . mockReturnValue ( true )
227+ mockGetPerUserMinimumLimit . mockReturnValue ( minimum )
228+ dbChainMockFns . limit . mockResolvedValueOnce ( [ { currentUsageLimit : '80' , creditBalance : '1' } ] )
229+
230+ await syncUsageLimitsFromSubscription ( 'user-1' )
231+
232+ expect ( mockGetHighestPriorityPersonalSubscription ) . toHaveBeenCalledExactlyOnceWith ( 'user-1' , {
233+ onError : 'throw' ,
234+ } )
235+ expect ( mockGetHighestPrioritySubscription ) . not . toHaveBeenCalled ( )
236+ expect ( mockGetPerUserMinimumLimit ) . toHaveBeenCalledWith ( personalSubscription )
237+ const update = dbChainMockFns . set . mock . calls [ 0 ] ?. [ 0 ]
238+ expect ( update ?. currentUsageLimit ) . not . toBeNull ( )
239+ expect ( JSON . stringify ( update ?. currentUsageLimit ) ) . toContain ( 'greatest' )
240+ }
241+ )
242+
243+ it ( 'does not reset a personal cap when its subscription lookup fails' , async ( ) => {
244+ mockGetHighestPriorityPersonalSubscription . mockRejectedValueOnce ( new Error ( 'db unavailable' ) )
245+ dbChainMockFns . limit . mockResolvedValueOnce ( [ { currentUsageLimit : '80' } ] )
246+
247+ await expect ( syncUsageLimitsFromSubscription ( 'user-1' ) ) . rejects . toThrow ( 'db unavailable' )
248+ expect ( dbChainMockFns . update ) . not . toHaveBeenCalled ( )
208249 } )
209250
210251 it ( 'raises a paid personal limit to plan base plus the exact prepaid balance' , async ( ) => {
211- mockGetHighestPrioritySubscription . mockResolvedValue ( PRO_SUBSCRIPTION )
252+ mockGetHighestPriorityPersonalSubscription . mockResolvedValue ( PRO_SUBSCRIPTION )
212253 mockGetPerUserMinimumLimit . mockReturnValue ( 40 )
213254 dbChainMockFns . limit . mockResolvedValueOnce ( [
214255 { currentUsageLimit : '40' , creditBalance : '0.005' } ,
@@ -224,7 +265,7 @@ describe('syncUsageLimitsFromSubscription', () => {
224265 } )
225266
226267 it ( 'restores free-tier base plus prepaid after a downgrade or org departure' , async ( ) => {
227- mockGetHighestPrioritySubscription . mockResolvedValue ( null )
268+ mockGetHighestPriorityPersonalSubscription . mockResolvedValue ( null )
228269 mockGetPerUserMinimumLimit . mockReturnValue ( 10 )
229270 dbChainMockFns . limit . mockResolvedValueOnce ( [
230271 { currentUsageLimit : null , creditBalance : '0.006' } ,
@@ -240,7 +281,7 @@ describe('syncUsageLimitsFromSubscription', () => {
240281 } )
241282
242283 it ( 'does not retain a higher paid custom cap after downgrade to free' , async ( ) => {
243- mockGetHighestPrioritySubscription . mockResolvedValue ( null )
284+ mockGetHighestPriorityPersonalSubscription . mockResolvedValue ( null )
244285 mockGetPerUserMinimumLimit . mockReturnValue ( 10 )
245286 dbChainMockFns . limit . mockResolvedValueOnce ( [
246287 { currentUsageLimit : '100' , creditBalance : '0.006' } ,
@@ -256,7 +297,7 @@ describe('syncUsageLimitsFromSubscription', () => {
256297 } )
257298
258299 it ( 'preserves a higher custom personal limit' , async ( ) => {
259- mockGetHighestPrioritySubscription . mockResolvedValue ( PRO_SUBSCRIPTION )
300+ mockGetHighestPriorityPersonalSubscription . mockResolvedValue ( PRO_SUBSCRIPTION )
260301 mockGetPerUserMinimumLimit . mockReturnValue ( 40 )
261302 dbChainMockFns . limit . mockResolvedValueOnce ( [ { currentUsageLimit : '50' , creditBalance : '1' } ] )
262303
0 commit comments