@@ -20,7 +20,7 @@ import * as Store from '../../Store.js'
2020import * as ChannelStore from '../session/ChannelStore.js'
2121import { signVoucher } from '../session/Voucher.js'
2222import { sessionManager } from '../client/SessionManager.js'
23- import { session } from './Session.js'
23+ import { charge , session } from './Session.js'
2424
2525const isLocalnet = nodeEnv === 'localnet'
2626const payer = accounts [ 2 ]
@@ -720,6 +720,133 @@ describe.runIf(isLocalnet)('session coverage gaps', () => {
720720 } )
721721 } )
722722
723+ describe ( 'PR7: multi top-up continuity' , ( ) => {
724+ test ( 'open -> topUp -> topUp -> voucher/charge -> close' , async ( ) => {
725+ const { channelId, serializedTransaction } = await createSignedOpenTransaction ( 4_000_000n )
726+ const server = createServer ( )
727+
728+ const openReceipt = await server . verify ( {
729+ credential : {
730+ challenge : makeChallenge ( { id : 'open-multi-topup' , channelId } ) ,
731+ payload : {
732+ action : 'open' as const ,
733+ type : 'transaction' as const ,
734+ channelId,
735+ transaction : serializedTransaction ,
736+ cumulativeAmount : '1000000' ,
737+ signature : await signVoucherFor ( payer , channelId , 1_000_000n ) ,
738+ } ,
739+ } ,
740+ request : makeRequest ( ) ,
741+ } )
742+ expect ( openReceipt . status ) . toBe ( 'success' )
743+
744+ await charge ( store , channelId , 1_000_000n )
745+ await expect ( charge ( store , channelId , 1_000_000n ) ) . rejects . toThrow ( 'requested' )
746+
747+ const topUp1Amount = 2_000_000n
748+ const { serializedTransaction : topUp1 } = await signTopUpChannel ( {
749+ escrow : escrowContract ,
750+ payer,
751+ channelId,
752+ token : currency ,
753+ amount : topUp1Amount ,
754+ } )
755+
756+ const topUp1Receipt = await server . verify ( {
757+ credential : {
758+ challenge : makeChallenge ( { id : 'topup-1' , channelId } ) ,
759+ payload : {
760+ action : 'topUp' as const ,
761+ type : 'transaction' as const ,
762+ channelId,
763+ transaction : topUp1 ,
764+ additionalDeposit : topUp1Amount . toString ( ) ,
765+ } ,
766+ } ,
767+ request : makeRequest ( ) ,
768+ } )
769+ expect ( topUp1Receipt . status ) . toBe ( 'success' )
770+ expect ( ( await store . getChannel ( channelId ) ) ?. deposit ) . toBe ( 6_000_000n )
771+
772+ const voucher1 = await server . verify ( {
773+ credential : {
774+ challenge : makeChallenge ( { id : 'voucher-after-topup-1' , channelId } ) ,
775+ payload : {
776+ action : 'voucher' as const ,
777+ channelId,
778+ cumulativeAmount : '3000000' ,
779+ signature : await signVoucherFor ( payer , channelId , 3_000_000n ) ,
780+ } ,
781+ } ,
782+ request : makeRequest ( ) ,
783+ } )
784+ expect ( voucher1 . acceptedCumulative ) . toBe ( '3000000' )
785+
786+ await charge ( store , channelId , 2_000_000n )
787+ await expect ( charge ( store , channelId , 1_000_000n ) ) . rejects . toThrow ( 'requested' )
788+
789+ const topUp2Amount = 2_000_000n
790+ const { serializedTransaction : topUp2 } = await signTopUpChannel ( {
791+ escrow : escrowContract ,
792+ payer,
793+ channelId,
794+ token : currency ,
795+ amount : topUp2Amount ,
796+ } )
797+
798+ const topUp2Receipt = await server . verify ( {
799+ credential : {
800+ challenge : makeChallenge ( { id : 'topup-2' , channelId } ) ,
801+ payload : {
802+ action : 'topUp' as const ,
803+ type : 'transaction' as const ,
804+ channelId,
805+ transaction : topUp2 ,
806+ additionalDeposit : topUp2Amount . toString ( ) ,
807+ } ,
808+ } ,
809+ request : makeRequest ( ) ,
810+ } )
811+ expect ( topUp2Receipt . status ) . toBe ( 'success' )
812+ expect ( ( await store . getChannel ( channelId ) ) ?. deposit ) . toBe ( 8_000_000n )
813+
814+ const voucher2 = await server . verify ( {
815+ credential : {
816+ challenge : makeChallenge ( { id : 'voucher-after-topup-2' , channelId } ) ,
817+ payload : {
818+ action : 'voucher' as const ,
819+ channelId,
820+ cumulativeAmount : '5000000' ,
821+ signature : await signVoucherFor ( payer , channelId , 5_000_000n ) ,
822+ } ,
823+ } ,
824+ request : makeRequest ( ) ,
825+ } )
826+ expect ( voucher2 . acceptedCumulative ) . toBe ( '5000000' )
827+
828+ await charge ( store , channelId , 2_000_000n )
829+
830+ const closeReceipt = await server . verify ( {
831+ credential : {
832+ challenge : makeChallenge ( { id : 'close-multi-topup' , channelId } ) ,
833+ payload : {
834+ action : 'close' as const ,
835+ channelId,
836+ cumulativeAmount : '5000000' ,
837+ signature : await signVoucherFor ( payer , channelId , 5_000_000n ) ,
838+ } ,
839+ } ,
840+ request : makeRequest ( ) ,
841+ } )
842+ expect ( closeReceipt . status ) . toBe ( 'success' )
843+
844+ const finalized = await store . getChannel ( channelId )
845+ expect ( finalized ?. spent ) . toBe ( 5_000_000n )
846+ expect ( finalized ?. finalized ) . toBe ( true )
847+ } )
848+ } )
849+
723850 describe ( 'PR7: e2e streaming loop' , ( ) => {
724851 test ( 'open -> stream -> need-voucher -> resume -> close' , async ( ) => {
725852 const backingStore = Store . memory ( )
@@ -798,5 +925,79 @@ describe.runIf(isLocalnet)('session coverage gaps', () => {
798925 const persisted = await ChannelStore . fromStore ( backingStore ) . getChannel ( channelId ! )
799926 expect ( persisted ?. finalized ) . toBe ( true )
800927 } )
928+
929+ test ( 'handles repeated exhaustion/resume cycles within one stream' , async ( ) => {
930+ const backingStore = Store . memory ( )
931+ const routeHandler = Mppx_server . create ( {
932+ methods : [
933+ tempo_server . session ( {
934+ store : backingStore ,
935+ getClient : ( ) => client ,
936+ account : recipient ,
937+ currency,
938+ escrowContract,
939+ chainId : chain . id ,
940+ sse : true ,
941+ } ) ,
942+ ] ,
943+ realm : 'api.example.com' ,
944+ secretKey : 'secret' ,
945+ } ) . session ( { amount : '1' , decimals : 6 , unitType : 'token' } )
946+
947+ let voucherPosts = 0
948+ const fetch = async ( input : RequestInfo | URL , init ?: RequestInit ) => {
949+ const request = new Request ( input , init )
950+ let action : 'open' | 'topUp' | 'voucher' | 'close' | undefined
951+
952+ if ( request . method === 'POST' && request . headers . has ( 'Authorization' ) ) {
953+ try {
954+ const credential = Credential . fromRequest < any > ( request )
955+ action = credential . payload ?. action
956+ if ( action === 'voucher' ) voucherPosts ++
957+ } catch { }
958+ }
959+
960+ const result = await routeHandler ( request )
961+ if ( result . status === 402 ) return result . challenge
962+
963+ if ( action === 'voucher' ) {
964+ return new Response ( null , { status : 200 } )
965+ }
966+
967+ if ( request . headers . get ( 'Accept' ) ?. includes ( 'text/event-stream' ) ) {
968+ return result . withReceipt ( async function * ( stream ) {
969+ await stream . charge ( )
970+ yield 'chunk-1'
971+ await stream . charge ( )
972+ yield 'chunk-2'
973+ await stream . charge ( )
974+ yield 'chunk-3'
975+ await stream . charge ( )
976+ yield 'chunk-4'
977+ } )
978+ }
979+
980+ return result . withReceipt ( new Response ( 'ok' ) )
981+ }
982+
983+ const manager = sessionManager ( {
984+ account : payer ,
985+ client,
986+ escrowContract,
987+ fetch,
988+ maxDeposit : '4' ,
989+ } )
990+
991+ const chunks : string [ ] = [ ]
992+ const stream = await manager . sse ( 'https://api.example.com/stream' )
993+ for await ( const chunk of stream ) chunks . push ( chunk )
994+
995+ expect ( chunks ) . toEqual ( [ 'chunk-1' , 'chunk-2' , 'chunk-3' , 'chunk-4' ] )
996+ expect ( voucherPosts ) . toBeGreaterThanOrEqual ( 2 )
997+
998+ const closeReceipt = await manager . close ( )
999+ expect ( closeReceipt ?. status ) . toBe ( 'success' )
1000+ expect ( closeReceipt ?. spent ) . toBe ( '4000000' )
1001+ } )
8011002 } )
8021003} )
0 commit comments