11import { describe , it , expect , beforeEach , jest } from '@jest/globals' ;
22
33const mockIsActive =
4- jest . fn < ( externalId : string ) => Promise < boolean > > ( ) ;
4+ jest . fn < ( externalId : string ) => Promise < SubscriptionState > > ( ) ;
5+ const mockFetchLiveEntitlements =
6+ jest . fn < ( externalId : string ) => Promise < SubscriptionState | null > > ( ) ;
57const mockGetExternalIdByUserId =
68 jest . fn < ( userId : number ) => Promise < string | null > > ( ) ;
79
8- // Replace the SubscriptionService class with one whose isActive we control.
10+ // Replace the SubscriptionService class with one whose methods we control.
911// Hoisted by jest before the module-under-test is imported, so the singleton
1012// in subscription.ts ends up holding our mock.
1113jest . mock ( '../../services/SubscriptionService' , ( ) => ( {
1214 SubscriptionService : jest . fn ( ) . mockImplementation ( ( ) => ( {
1315 isActive : mockIsActive ,
16+ fetchLiveEntitlements : mockFetchLiveEntitlements ,
1417 } ) ) ,
1518} ) ) ;
1619
@@ -21,7 +24,8 @@ jest.mock('../../services/db/UserDB', () => ({
2124} ) ) ;
2225
2326// eslint-disable-next-line import/first
24- import { checkSubscription } from '../../api/middlewares/subscription' ;
27+ import { checkSubscription , requireSubscription } from '../../api/middlewares/subscription' ;
28+ import { SubscriptionState , SubscriptionTierEnum } from '../../types/user' ;
2529
2630describe ( 'checkSubscription middleware' , ( ) => {
2731 let req : any ;
@@ -49,7 +53,7 @@ describe('checkSubscription middleware', () => {
4953 } ) ;
5054
5155 it ( 'calls next() when isActive returns true' , async ( ) => {
52- mockIsActive . mockResolvedValue ( true ) ;
56+ mockIsActive . mockResolvedValue ( { active : true , verified : 'local' , subscriptions : [ ] } ) ;
5357 await checkSubscription ( req , res , next ) ;
5458 expect ( mockIsActive ) . toHaveBeenCalledWith ( 'ext-1' ) ;
5559 expect ( next ) . toHaveBeenCalledTimes ( 1 ) ;
@@ -58,7 +62,7 @@ describe('checkSubscription middleware', () => {
5862 } ) ;
5963
6064 it ( 'returns 400 "not subscribed" when isActive returns false' , async ( ) => {
61- mockIsActive . mockResolvedValue ( false ) ;
65+ mockIsActive . mockResolvedValue ( { active : false , verified : 'local' , subscriptions : [ ] } ) ;
6266 await checkSubscription ( req , res , next ) ;
6367 expect ( mockIsActive ) . toHaveBeenCalledWith ( 'ext-1' ) ;
6468 expect ( res . status ) . toHaveBeenCalledWith ( 400 ) ;
@@ -77,7 +81,7 @@ describe('checkSubscription middleware', () => {
7781 it ( 'falls back to DB lookup when JWT lacks external_id (legacy Apple login)' , async ( ) => {
7882 req . user = { id_user : 42 } ; // no external_id — pre-fix Apple JWT shape
7983 mockGetExternalIdByUserId . mockResolvedValue ( 'ext-from-db' ) ;
80- mockIsActive . mockResolvedValue ( true ) ;
84+ mockIsActive . mockResolvedValue ( { active : true , verified : 'local' , subscriptions : [ ] } ) ;
8185
8286 await checkSubscription ( req , res , next ) ;
8387
@@ -86,3 +90,109 @@ describe('checkSubscription middleware', () => {
8690 expect ( next ) . toHaveBeenCalledTimes ( 1 ) ;
8791 } ) ;
8892} ) ;
93+
94+ describe ( 'requireSubscription middleware' , ( ) => {
95+ let res : any ;
96+ let next : jest . Mock ;
97+
98+ beforeEach ( ( ) => {
99+ res = {
100+ status : jest . fn ( ) . mockReturnThis ( ) ,
101+ json : jest . fn ( ) . mockReturnThis ( ) ,
102+ } ;
103+ next = jest . fn ( ) ;
104+ mockFetchLiveEntitlements . mockReset ( ) ;
105+ mockGetExternalIdByUserId . mockReset ( ) ;
106+ // Default: live RC check finds no upgrade.
107+ mockFetchLiveEntitlements . mockResolvedValue ( null ) ;
108+ mockGetExternalIdByUserId . mockResolvedValue ( 'ext-1' ) ;
109+ } ) ;
110+
111+ it ( 'calls next() when the user holds an allowed tier (no RC call)' , async ( ) => {
112+ const req : any = { user : { id_user : 1 , external_id : 'ext-1' , subscriptions : [ SubscriptionTierEnum . PRO ] } } ;
113+ await requireSubscription ( [ SubscriptionTierEnum . PRO ] ) ( req , res , next ) ;
114+ expect ( next ) . toHaveBeenCalledTimes ( 1 ) ;
115+ expect ( res . status ) . not . toHaveBeenCalled ( ) ;
116+ expect ( mockFetchLiveEntitlements ) . not . toHaveBeenCalled ( ) ;
117+ } ) ;
118+
119+ it ( 'matches a tier anywhere in the subscriptions array (not just index 0)' , async ( ) => {
120+ const req : any = {
121+ user : { id_user : 1 , external_id : 'ext-1' , subscriptions : [ SubscriptionTierEnum . LITE , SubscriptionTierEnum . PRO ] } ,
122+ } ;
123+ await requireSubscription ( [ SubscriptionTierEnum . PRO ] ) ( req , res , next ) ;
124+ expect ( next ) . toHaveBeenCalledTimes ( 1 ) ;
125+ expect ( mockFetchLiveEntitlements ) . not . toHaveBeenCalled ( ) ;
126+ } ) ;
127+
128+ it ( 'falls back to RC and allows the action when RC confirms the upgraded tier (lite → pro)' , async ( ) => {
129+ const req : any = { user : { id_user : 1 , external_id : 'ext-1' , subscriptions : [ SubscriptionTierEnum . LITE ] } } ;
130+ mockFetchLiveEntitlements . mockResolvedValue ( {
131+ active : true ,
132+ verified : 'rc' ,
133+ subscriptions : [ 'pro' ] ,
134+ } ) ;
135+
136+ await requireSubscription ( [ SubscriptionTierEnum . PRO ] ) ( req , res , next ) ;
137+
138+ expect ( mockFetchLiveEntitlements ) . toHaveBeenCalledWith ( 'ext-1' ) ;
139+ expect ( next ) . toHaveBeenCalledTimes ( 1 ) ;
140+ expect ( res . status ) . not . toHaveBeenCalled ( ) ;
141+ // req.user.subscriptions refreshed for downstream handlers
142+ expect ( req . user . subscriptions ) . toEqual ( [ 'pro' ] ) ;
143+ } ) ;
144+
145+ it ( 'returns 403 when RC also lacks the required tier (no real upgrade)' , async ( ) => {
146+ const req : any = { user : { id_user : 1 , external_id : 'ext-1' , subscriptions : [ SubscriptionTierEnum . LITE ] } } ;
147+ mockFetchLiveEntitlements . mockResolvedValue ( {
148+ active : true ,
149+ verified : 'rc' ,
150+ subscriptions : [ 'lite' ] ,
151+ } ) ;
152+
153+ await requireSubscription ( [ SubscriptionTierEnum . PRO ] ) ( req , res , next ) ;
154+
155+ expect ( mockFetchLiveEntitlements ) . toHaveBeenCalledWith ( 'ext-1' ) ;
156+ expect ( res . status ) . toHaveBeenCalledWith ( 403 ) ;
157+ expect ( res . json ) . toHaveBeenCalledWith ( { message : 'Requires one of: pro' } ) ;
158+ expect ( next ) . not . toHaveBeenCalled ( ) ;
159+ } ) ;
160+
161+ it ( 'returns 403 when RC is unreachable (fetchLiveEntitlements returns null)' , async ( ) => {
162+ const req : any = { user : { id_user : 1 , external_id : 'ext-1' , subscriptions : [ SubscriptionTierEnum . FREE ] } } ;
163+ mockFetchLiveEntitlements . mockResolvedValue ( null ) ;
164+
165+ await requireSubscription ( [ SubscriptionTierEnum . PRO ] ) ( req , res , next ) ;
166+
167+ expect ( res . status ) . toHaveBeenCalledWith ( 403 ) ;
168+ expect ( next ) . not . toHaveBeenCalled ( ) ;
169+ } ) ;
170+
171+ it ( 'resolves externalId via DB lookup when the JWT lacks external_id, then checks RC' , async ( ) => {
172+ const req : any = { user : { id_user : 42 , subscriptions : [ SubscriptionTierEnum . LITE ] } } ;
173+ mockGetExternalIdByUserId . mockResolvedValue ( 'ext-from-db' ) ;
174+ mockFetchLiveEntitlements . mockResolvedValue ( { active : true , verified : 'rc' , subscriptions : [ 'pro' ] } ) ;
175+
176+ await requireSubscription ( [ SubscriptionTierEnum . PRO ] ) ( req , res , next ) ;
177+
178+ expect ( mockGetExternalIdByUserId ) . toHaveBeenCalledWith ( 42 ) ;
179+ expect ( mockFetchLiveEntitlements ) . toHaveBeenCalledWith ( 'ext-from-db' ) ;
180+ expect ( next ) . toHaveBeenCalledTimes ( 1 ) ;
181+ } ) ;
182+
183+ it ( 'returns 403 (does not throw) when subscriptions is undefined and RC finds nothing' , async ( ) => {
184+ const req : any = { user : { id_user : 1 , external_id : 'ext-1' } } ;
185+ await requireSubscription ( [ SubscriptionTierEnum . PRO ] ) ( req , res , next ) ;
186+ expect ( res . status ) . toHaveBeenCalledWith ( 403 ) ;
187+ expect ( next ) . not . toHaveBeenCalled ( ) ;
188+ } ) ;
189+
190+ it ( 'returns 400 with a message when req.user is missing' , async ( ) => {
191+ const req : any = { } ;
192+ await requireSubscription ( [ SubscriptionTierEnum . PRO ] ) ( req , res , next ) ;
193+ expect ( res . status ) . toHaveBeenCalledWith ( 400 ) ;
194+ expect ( res . json ) . toHaveBeenCalledWith ( { message : 'User data missing.' } ) ;
195+ expect ( next ) . not . toHaveBeenCalled ( ) ;
196+ expect ( mockFetchLiveEntitlements ) . not . toHaveBeenCalled ( ) ;
197+ } ) ;
198+ } ) ;
0 commit comments