@@ -70,7 +70,25 @@ async function post(apiPath: string, body: unknown) {
7070 body : JSON . stringify ( body ) ,
7171 } ) ;
7272 const data = await response . json ( ) . catch ( ( ) => null ) ;
73- return { status : response . status , data } ;
73+ return { status : response . status , data, headers : response . headers } ;
74+ }
75+
76+ async function postWithHeaders (
77+ apiPath : string ,
78+ body : unknown ,
79+ headers : Record < string , string > ,
80+ ) {
81+ const mergedHeaders : Record < string , string > = {
82+ 'Content-Type' : 'application/json' ,
83+ ...headers ,
84+ } ;
85+ const response = await fetch ( `${ baseUrl } ${ apiPath } ` , {
86+ method : 'POST' ,
87+ headers : mergedHeaders ,
88+ body : JSON . stringify ( body ) ,
89+ } ) ;
90+ const data = await response . json ( ) . catch ( ( ) => null ) ;
91+ return { status : response . status , data, headers : response . headers } ;
7492}
7593
7694async function get ( apiPath : string ) {
@@ -647,3 +665,168 @@ describe('GET /api/stats', () => {
647665 } ) ;
648666 } ) ;
649667} ) ;
668+
669+ describe ( 'POST /api/campaigns/:id/pledges with Idempotency-Key' , ( ) => {
670+ const CONTRIBUTOR_C = `G${ 'D' . repeat ( 55 ) } ` ;
671+ const CONTRIBUTOR_D = `G${ 'E' . repeat ( 55 ) } ` ;
672+
673+ async function createTestCampaign ( ) {
674+ const createRes = await post ( '/api/campaigns' , {
675+ creator : CREATOR ,
676+ title : 'Idempotency Test Campaign' ,
677+ description : 'This campaign is used to test idempotency behavior.' ,
678+ acceptedTokens : [ 'USDC' ] ,
679+ targetAmount : 500 ,
680+ deadline : Math . floor ( Date . now ( ) / 1000 ) + 86400 ,
681+ } ) ;
682+ return createRes . data . data . id ;
683+ }
684+
685+ it ( 'request with Idempotency-Key creates a pledge' , async ( ) => {
686+ const campaignId = await createTestCampaign ( ) ;
687+
688+ const res = await post ( `/api/campaigns/${ campaignId } /pledges` , {
689+ contributor : CONTRIBUTOR_C ,
690+ amount : 100 ,
691+ assetCode : 'USDC' ,
692+ } , { 'Idempotency-Key' : 'test-key-1' } ) ;
693+
694+ expect ( res . status ) . toBe ( 201 ) ;
695+ expect ( res . data . data . progress . pledgeCount ) . toBe ( 1 ) ;
696+ } ) ;
697+
698+ it ( 'duplicate request with same Idempotency-Key returns cached response' , async ( ) => {
699+ const campaignId = await createTestCampaign ( ) ;
700+
701+ const firstRes = await post ( `/api/campaigns/${ campaignId } /pledges` , {
702+ contributor : CONTRIBUTOR_C ,
703+ amount : 100 ,
704+ assetCode : 'USDC' ,
705+ } , { 'Idempotency-Key' : 'dup-key-1' } ) ;
706+ expect ( firstRes . status ) . toBe ( 201 ) ;
707+
708+ const secondRes = await post ( `/api/campaigns/${ campaignId } /pledges` , {
709+ contributor : CONTRIBUTOR_C ,
710+ amount : 100 ,
711+ assetCode : 'USDC' ,
712+ } , { 'Idempotency-Key' : 'dup-key-1' } ) ;
713+ expect ( secondRes . status ) . toBe ( 201 ) ;
714+ expect ( secondRes . data ) . toEqual ( firstRes . data ) ;
715+ } ) ;
716+
717+ it ( 'duplicate request with same Idempotency-Key performs only one database write' , async ( ) => {
718+ const campaignId = await createTestCampaign ( ) ;
719+
720+ await post ( `/api/campaigns/${ campaignId } /pledges` , {
721+ contributor : CONTRIBUTOR_C ,
722+ amount : 100 ,
723+ assetCode : 'USDC' ,
724+ } , { 'Idempotency-Key' : 'db-write-key' } ) ;
725+
726+ const db = getDb ( ) ;
727+ const pledgeCountBefore = db . prepare ( 'SELECT COUNT(*) AS count FROM pledges' ) . get ( ) as { count : number } ;
728+
729+ await post ( `/api/campaigns/${ campaignId } /pledges` , {
730+ contributor : CONTRIBUTOR_C ,
731+ amount : 100 ,
732+ assetCode : 'USDC' ,
733+ } , { 'Idempotency-Key' : 'db-write-key' } ) ;
734+
735+ const pledgeCountAfter = db . prepare ( 'SELECT COUNT(*) AS count FROM pledges' ) . get ( ) as { count : number } ;
736+ expect ( pledgeCountAfter . count ) . toBe ( pledgeCountBefore . count ) ;
737+ } ) ;
738+
739+ it ( 'missing Idempotency-Key behaves exactly as before' , async ( ) => {
740+ const campaignId = await createTestCampaign ( ) ;
741+
742+ const res = await post ( `/api/campaigns/${ campaignId } /pledges` , {
743+ contributor : CONTRIBUTOR_C ,
744+ amount : 100 ,
745+ assetCode : 'USDC' ,
746+ } ) ;
747+ expect ( res . status ) . toBe ( 201 ) ;
748+ expect ( res . data . data . progress . pledgeCount ) . toBe ( 1 ) ;
749+ } ) ;
750+
751+ it ( 'different idempotency keys create independent pledges' , async ( ) => {
752+ const campaignId = await createTestCampaign ( ) ;
753+
754+ const res1 = await post ( `/api/campaigns/${ campaignId } /pledges` , {
755+ contributor : CONTRIBUTOR_C ,
756+ amount : 50 ,
757+ assetCode : 'USDC' ,
758+ } , { 'Idempotency-Key' : 'key-A' } ) ;
759+ expect ( res1 . status ) . toBe ( 201 ) ;
760+
761+ const res2 = await post ( `/api/campaigns/${ campaignId } /pledges` , {
762+ contributor : CONTRIBUTOR_C ,
763+ amount : 50 ,
764+ assetCode : 'USDC' ,
765+ } , { 'Idempotency-Key' : 'key-B' } ) ;
766+ expect ( res2 . status ) . toBe ( 201 ) ;
767+
768+ const db = getDb ( ) ;
769+ const pledgeCount = db . prepare ( 'SELECT COUNT(*) AS count FROM pledges' ) . get ( ) as { count : number } ;
770+ expect ( pledgeCount . count ) . toBe ( 2 ) ;
771+ } ) ;
772+
773+ it ( 'different users using the same idempotency key do not share cached responses' , async ( ) => {
774+ const campaignId = await createTestCampaign ( ) ;
775+
776+ const userARes = await post ( `/api/campaigns/${ campaignId } /pledges` , {
777+ contributor : CONTRIBUTOR_C ,
778+ amount : 100 ,
779+ assetCode : 'USDC' ,
780+ } , { 'Idempotency-Key' : 'shared-key' } ) ;
781+ expect ( userARes . status ) . toBe ( 201 ) ;
782+ expect ( userARes . data . data . progress . pledgeCount ) . toBe ( 1 ) ;
783+
784+ const userBRes = await post ( `/api/campaigns/${ campaignId } /pledges` , {
785+ contributor : CONTRIBUTOR_D ,
786+ amount : 100 ,
787+ assetCode : 'USDC' ,
788+ } , { 'Idempotency-Key' : 'shared-key' } ) ;
789+ expect ( userBRes . status ) . toBe ( 201 ) ;
790+ expect ( userBRes . data . data . progress . pledgeCount ) . toBe ( 2 ) ;
791+ } ) ;
792+
793+ it ( 'cached response preserves original status and payload' , async ( ) => {
794+ const campaignId = await createTestCampaign ( ) ;
795+
796+ const firstRes = await post ( `/api/campaigns/${ campaignId } /pledges` , {
797+ contributor : CONTRIBUTOR_C ,
798+ amount : 75 ,
799+ assetCode : 'USDC' ,
800+ } , { 'Idempotency-Key' : 'status-payload-key' } ) ;
801+ expect ( firstRes . status ) . toBe ( 201 ) ;
802+
803+ const cachedRes = await post ( `/api/campaigns/${ campaignId } /pledges` , {
804+ contributor : CONTRIBUTOR_C ,
805+ amount : 75 ,
806+ assetCode : 'USDC' ,
807+ } , { 'Idempotency-Key' : 'status-payload-key' } ) ;
808+ expect ( cachedRes . status ) . toBe ( 201 ) ;
809+ expect ( cachedRes . data . data . id ) . toBe ( firstRes . data . data . id ) ;
810+ expect ( cachedRes . data . data . amount ) . toBe ( firstRes . data . data . amount ) ;
811+ } ) ;
812+
813+ it ( 'X-Idempotency-Cache header is MISS on first request and HIT on duplicate' , async ( ) => {
814+ const campaignId = await createTestCampaign ( ) ;
815+
816+ const firstRes = await postWithHeaders (
817+ `/api/campaigns/${ campaignId } /pledges` ,
818+ { contributor : CONTRIBUTOR_C , amount : 100 , assetCode : 'USDC' } ,
819+ { 'Idempotency-Key' : 'header-test-key' } ,
820+ ) ;
821+ expect ( firstRes . status ) . toBe ( 201 ) ;
822+ expect ( firstRes . headers . get ( 'X-Idempotency-Cache' ) ) . toBe ( 'MISS' ) ;
823+
824+ const secondRes = await postWithHeaders (
825+ `/api/campaigns/${ campaignId } /pledges` ,
826+ { contributor : CONTRIBUTOR_C , amount : 100 , assetCode : 'USDC' } ,
827+ { 'Idempotency-Key' : 'header-test-key' } ,
828+ ) ;
829+ expect ( secondRes . status ) . toBe ( 201 ) ;
830+ expect ( secondRes . headers . get ( 'X-Idempotency-Cache' ) ) . toBe ( 'HIT' ) ;
831+ } ) ;
832+ } ) ;
0 commit comments