@@ -3,6 +3,15 @@ import fs from 'fs';
33import { Server } from 'http' ;
44import path from 'path' ;
55import { afterAll , beforeAll , beforeEach , describe , expect , it , vi } from 'vitest' ;
6+
7+ // The write rate limit (default 20/min) is a module-level constant in `./index`,
8+ // evaluated at import time. This suite exercises many archive/restore/pledge
9+ // mutations across its describe blocks, so raise the limit before `./index`
10+ // is imported to avoid tripping 429s on unrelated test assertions.
11+ vi . hoisted ( ( ) => {
12+ process . env . RATE_LIMIT_WRITE_LIMIT = '1000' ;
13+ } ) ;
14+
615import { app } from './index' ;
716import { initCampaignStore } from './services/campaignStore' ;
817import { getDb } from './services/db' ;
@@ -430,6 +439,144 @@ describe('Campaign maxPerContributor Field', () => {
430439 } ) ;
431440} ) ;
432441
442+ describe ( 'Campaign archive (soft delete) and restore' , ( ) => {
443+ async function get ( apiPath : string ) {
444+ const response = await fetch ( `${ baseUrl } ${ apiPath } ` ) ;
445+ const data = await response . json ( ) . catch ( ( ) => null ) ;
446+ return { status : response . status , data } ;
447+ }
448+
449+ async function post ( apiPath : string , body ?: unknown ) {
450+ const response = await fetch ( `${ baseUrl } ${ apiPath } ` , {
451+ method : 'POST' ,
452+ headers : { 'Content-Type' : 'application/json' } ,
453+ body : body !== undefined ? JSON . stringify ( body ) : undefined ,
454+ } ) ;
455+ const data = await response . json ( ) . catch ( ( ) => null ) ;
456+ return { status : response . status , data } ;
457+ }
458+
459+ async function del ( apiPath : string ) {
460+ const response = await fetch ( `${ baseUrl } ${ apiPath } ` , { method : 'DELETE' } ) ;
461+ const data = await response . json ( ) . catch ( ( ) => null ) ;
462+ return { status : response . status , data } ;
463+ }
464+
465+ async function createTestCampaign ( title : string ) {
466+ const now = Math . floor ( Date . now ( ) / 1000 ) ;
467+ const res = await post ( '/api/campaigns' , {
468+ creator : CREATOR ,
469+ title,
470+ description : 'A campaign used to exercise archive/restore behavior' ,
471+ acceptedTokens : [ 'USDC' ] ,
472+ targetAmount : 100 ,
473+ deadline : now + 3600 ,
474+ } ) ;
475+ expect ( res . status ) . toBe ( 201 ) ;
476+ return res . data . data . id as string ;
477+ }
478+
479+ it ( 'DELETE /api/campaigns/:id archives the campaign and sets deletedAt' , async ( ) => {
480+ const campaignId = await createTestCampaign ( 'Archive me' ) ;
481+
482+ const deleteRes = await del ( `/api/campaigns/${ campaignId } ` ) ;
483+ expect ( deleteRes . status ) . toBe ( 200 ) ;
484+ expect ( deleteRes . data . data . deletedAt ) . toBeDefined ( ) ;
485+ } ) ;
486+
487+ it ( 'archived campaigns are excluded from the default GET /api/campaigns list' , async ( ) => {
488+ const campaignId = await createTestCampaign ( 'Hidden after archive' ) ;
489+ await del ( `/api/campaigns/${ campaignId } ` ) ;
490+
491+ const listRes = await get ( '/api/campaigns?page=1&limit=50' ) ;
492+ expect ( listRes . status ) . toBe ( 200 ) ;
493+ expect ( listRes . data . data . some ( ( c : { id : string } ) => c . id === campaignId ) ) . toBe ( false ) ;
494+ } ) ;
495+
496+ it ( 'GET /api/campaigns?includeDeleted=true includes archived campaigns' , async ( ) => {
497+ const campaignId = await createTestCampaign ( 'Visible with includeDeleted' ) ;
498+ await del ( `/api/campaigns/${ campaignId } ` ) ;
499+
500+ const listRes = await get ( '/api/campaigns?page=1&limit=50&includeDeleted=true' ) ;
501+ expect ( listRes . status ) . toBe ( 200 ) ;
502+ expect ( listRes . data . data . some ( ( c : { id : string } ) => c . id === campaignId ) ) . toBe ( true ) ;
503+ } ) ;
504+
505+ it ( 'GET /api/campaigns?include_archived=true is accepted as an alias for includeDeleted' , async ( ) => {
506+ const campaignId = await createTestCampaign ( 'Visible with include_archived' ) ;
507+ await del ( `/api/campaigns/${ campaignId } ` ) ;
508+
509+ const listRes = await get ( '/api/campaigns?page=1&limit=50&include_archived=true' ) ;
510+ expect ( listRes . status ) . toBe ( 200 ) ;
511+ expect ( listRes . data . data . some ( ( c : { id : string } ) => c . id === campaignId ) ) . toBe ( true ) ;
512+ } ) ;
513+
514+ it ( 'DELETE on an already-archived campaign returns 409' , async ( ) => {
515+ const campaignId = await createTestCampaign ( 'Double archive' ) ;
516+ await del ( `/api/campaigns/${ campaignId } ` ) ;
517+
518+ const secondDelete = await del ( `/api/campaigns/${ campaignId } ` ) ;
519+ expect ( secondDelete . status ) . toBe ( 409 ) ;
520+ expect ( secondDelete . data . error . code ) . toBe ( 'ALREADY_DELETED' ) ;
521+ } ) ;
522+
523+ it ( 'DELETE on a nonexistent campaign returns 404' , async ( ) => {
524+ const res = await del ( '/api/campaigns/999999' ) ;
525+ expect ( res . status ) . toBe ( 404 ) ;
526+ expect ( res . data . error . code ) . toBe ( 'NOT_FOUND' ) ;
527+ } ) ;
528+
529+ it ( 'POST /api/campaigns/:id/restore un-archives the campaign' , async ( ) => {
530+ const campaignId = await createTestCampaign ( 'Restore me' ) ;
531+ await del ( `/api/campaigns/${ campaignId } ` ) ;
532+
533+ const restoreRes = await post ( `/api/campaigns/${ campaignId } /restore` ) ;
534+ expect ( restoreRes . status ) . toBe ( 200 ) ;
535+ expect ( restoreRes . data . data . deletedAt ) . toBeUndefined ( ) ;
536+
537+ const listRes = await get ( '/api/campaigns?page=1&limit=50' ) ;
538+ expect ( listRes . data . data . some ( ( c : { id : string } ) => c . id === campaignId ) ) . toBe ( true ) ;
539+ } ) ;
540+
541+ it ( 'POST restore on a campaign that is not archived returns 409' , async ( ) => {
542+ const campaignId = await createTestCampaign ( 'Never archived' ) ;
543+ const res = await post ( `/api/campaigns/${ campaignId } /restore` ) ;
544+ expect ( res . status ) . toBe ( 409 ) ;
545+ expect ( res . data . error . code ) . toBe ( 'NOT_ARCHIVED' ) ;
546+ } ) ;
547+
548+ it ( 'POST restore on a nonexistent campaign returns 404' , async ( ) => {
549+ const res = await post ( '/api/campaigns/999999/restore' ) ;
550+ expect ( res . status ) . toBe ( 404 ) ;
551+ expect ( res . data . error . code ) . toBe ( 'NOT_FOUND' ) ;
552+ } ) ;
553+
554+ it ( 'preserves pledges and history through an archive + restore cycle' , async ( ) => {
555+ const campaignId = await createTestCampaign ( 'Preserve pledges' ) ;
556+ const pledgeRes = await post ( `/api/campaigns/${ campaignId } /pledges` , {
557+ contributor : CONTRIBUTOR ,
558+ amount : 25 ,
559+ assetCode : 'USDC' ,
560+ } ) ;
561+ expect ( pledgeRes . status ) . toBe ( 201 ) ;
562+
563+ await del ( `/api/campaigns/${ campaignId } ` ) ;
564+ await post ( `/api/campaigns/${ campaignId } /restore` ) ;
565+
566+ const detailRes = await get ( `/api/campaigns/${ campaignId } ` ) ;
567+ expect ( detailRes . status ) . toBe ( 200 ) ;
568+ expect ( detailRes . data . data . pledgedAmount ) . toBe ( 25 ) ;
569+
570+ const historyRes = await get ( `/api/campaigns/${ campaignId } /history` ) ;
571+ expect ( historyRes . status ) . toBe ( 200 ) ;
572+ const eventTypes = historyRes . data . data . map ( ( e : { eventType : string } ) => e . eventType ) ;
573+ expect ( eventTypes ) . toContain ( 'created' ) ;
574+ expect ( eventTypes ) . toContain ( 'pledged' ) ;
575+ expect ( eventTypes ) . toContain ( 'archived' ) ;
576+ expect ( eventTypes ) . toContain ( 'restored' ) ;
577+ } ) ;
578+ } ) ;
579+
433580describe ( 'GET /api/stats' , ( ) => {
434581 it ( 'returns aggregate metrics in the correct format' , async ( ) => {
435582 const res = await get ( '/api/stats' ) ;
0 commit comments