Skip to content

Commit f579f3d

Browse files
authored
Merge pull request #751 from 0xElyte/main
fix: GET /api/campaigns returns incorrect status when deadline is exa…
2 parents ab90186 + e9f2ed9 commit f579f3d

3 files changed

Lines changed: 86 additions & 24 deletions

File tree

backend/src/api.test.ts

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ vi.hoisted(() => {
1313
});
1414

1515
import { app } from './index';
16-
import { initCampaignStore } from './services/campaignStore';
16+
import { createCampaign, initCampaignStore } from './services/campaignStore';
1717
import { getDb } from './services/db';
1818

1919
// Mock sorobanRpc to avoid real network calls during tests
@@ -418,6 +418,44 @@ describe('Campaign maxPerContributor Field', () => {
418418
expect(campaign.maxPerContributor).toBe(50);
419419
});
420420

421+
it('keeps a campaign open at the exact deadline and fails it 1ms later', async () => {
422+
const fixedNow = 1_700_000_000_000;
423+
const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(fixedNow);
424+
425+
try {
426+
const campaign = createCampaign({
427+
creator: CREATOR,
428+
title: 'Exact deadline boundary campaign',
429+
description: 'Boundary test for exact deadline status consistency',
430+
acceptedTokens: ['USDC'],
431+
targetAmount: 100,
432+
deadline: Math.floor(fixedNow / 1000),
433+
});
434+
435+
const firstCall = await get('/api/campaigns?page=1&limit=10');
436+
expect(firstCall.status).toBe(200);
437+
438+
const firstListedCampaign = firstCall.data.data.find((item: { id: string; progress: { status: string } }) => item.id === campaign.id);
439+
expect(firstListedCampaign?.progress.status).toBe('open');
440+
441+
const secondCall = await get('/api/campaigns?page=1&limit=10');
442+
expect(secondCall.status).toBe(200);
443+
444+
const secondListedCampaign = secondCall.data.data.find((item: { id: string; progress: { status: string } }) => item.id === campaign.id);
445+
expect(secondListedCampaign?.progress.status).toBe('open');
446+
447+
nowSpy.mockReturnValue(fixedNow + 1);
448+
449+
const oneMillisecondLater = await get('/api/campaigns?page=1&limit=10');
450+
expect(oneMillisecondLater.status).toBe(200);
451+
452+
const failedCampaign = oneMillisecondLater.data.data.find((item: { id: string; progress: { status: string } }) => item.id === campaign.id);
453+
expect(failedCampaign?.progress.status).toBe('failed');
454+
} finally {
455+
nowSpy.mockRestore();
456+
}
457+
});
458+
421459
it('includes maxPerContributor in GET /api/campaigns/:id detail response', async () => {
422460
const now = Math.floor(Date.now() / 1000);
423461

backend/src/services/__tests__/mutation.test.ts

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -126,21 +126,30 @@ describe('calculateProgress – boundary conditions', () => {
126126
expect(progress.canRefund).toBe(false);
127127
});
128128

129-
it('is "failed" when deadline == now (at boundary)', () => {
130-
const now = Math.floor(Date.now() / 1000);
129+
it('is "open" at the exact deadline and fails only after it passes', () => {
130+
const now = Date.now();
131131
const campaign = createCampaign({
132132
creator: CREATOR,
133-
title: 'Boundary failed',
133+
title: 'Boundary open',
134134
description: 'desc',
135135
assetCode: 'USDC',
136136
targetAmount: 100,
137-
deadline: now,
137+
deadline: Math.floor(now / 1000) + 1,
138138
});
139-
// Evaluate exactly AT the deadline
140-
const progress = calculateProgress(campaign, campaign.deadline);
141-
expect(progress.status).toBe('failed');
142-
expect(progress.canPledge).toBe(false);
143-
expect(progress.canRefund).toBe(true);
139+
campaign.deadline = now / 1000;
140+
141+
const exactBoundary = calculateProgress(campaign, now);
142+
expect(exactBoundary.status).toBe('open');
143+
expect(exactBoundary.canPledge).toBe(true);
144+
expect(exactBoundary.canRefund).toBe(false);
145+
146+
const oneMillisecondBefore = calculateProgress(campaign, now - 1);
147+
expect(oneMillisecondBefore.status).toBe('open');
148+
149+
const oneMillisecondAfter = calculateProgress(campaign, now + 1);
150+
expect(oneMillisecondAfter.status).toBe('failed');
151+
expect(oneMillisecondAfter.canPledge).toBe(false);
152+
expect(oneMillisecondAfter.canRefund).toBe(true);
144153
});
145154

146155
it('is "funded" when pledgedAmount exactly equals targetAmount before deadline', () => {
@@ -248,7 +257,7 @@ describe('calculateProgress – boundary conditions', () => {
248257
deadline: future(3600), // 1 hour in the future
249258
});
250259
// Evaluate 2 hours AFTER the deadline → hoursLeft should be 0
251-
const evalAt = campaign.deadline + 7200;
260+
const evalAt = campaign.deadline * 1000 + 7200 * 1000;
252261
const progress = calculateProgress(campaign, evalAt);
253262
expect(progress.hoursLeft).toBe(0);
254263
});

backend/src/services/campaignStore.ts

Lines changed: 28 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,10 @@ function nowInSeconds(): number {
129129
return Math.floor(Date.now() / 1000);
130130
}
131131

132+
function nowInMilliseconds(): number {
133+
return Date.now();
134+
}
135+
132136
function round(value: number): number {
133137
return Number(value.toFixed(2));
134138
}
@@ -242,10 +246,11 @@ function checkContributorLimit(
242246
*/
243247
export function calculateProgress(
244248
campaign: CampaignRecord,
245-
at = nowInSeconds(),
249+
at = nowInMilliseconds(),
246250
pledgeCount?: number,
247251
): CampaignProgress {
248-
const deadlineReached = at >= campaign.deadline;
252+
const deadlineAt = campaign.deadline * 1000;
253+
const deadlineReached = at > deadlineAt;
249254
const canClaim =
250255
campaign.claimedAt === undefined &&
251256
deadlineReached &&
@@ -270,7 +275,7 @@ export function calculateProgress(
270275
percentFunded: round((campaign.pledgedAmount / campaign.targetAmount) * 100),
271276
remainingAmount: round(Math.max(0, campaign.targetAmount - campaign.pledgedAmount)),
272277
pledgeCount: pledgeCount ?? getActivePledgeCount(campaign.id),
273-
hoursLeft: round(Math.max(0, campaign.deadline - at) / 3600),
278+
hoursLeft: round(Math.max(0, deadlineAt - at) / 3600000),
274279
canPledge,
275280
canClaim,
276281
canRefund,
@@ -389,7 +394,7 @@ if (options?.searchQuery && options.searchQuery.trim()) {
389394
}
390395

391396
if (options?.status) {
392-
const now = Math.floor(Date.now() / 1000);
397+
const now = nowInMilliseconds();
393398
switch (options.status) {
394399
case 'claimed':
395400
whereClauses.push(`claimed_at IS NOT NULL`);
@@ -399,12 +404,12 @@ if (options?.searchQuery && options.searchQuery.trim()) {
399404
break;
400405
case 'failed':
401406
whereClauses.push(
402-
`campaigns.claimed_at IS NULL AND campaigns.pledged_amount < campaigns.target_amount AND campaigns.deadline <= ?`,
407+
`campaigns.claimed_at IS NULL AND campaigns.pledged_amount < campaigns.target_amount AND campaigns.deadline * 1000 < ?`,
403408
);
404409
params.push(now);
405410
break;
406411
case 'open':
407-
whereClauses.push(`claimed_at IS NULL AND pledged_amount < target_amount AND deadline > ?`);
412+
whereClauses.push(`claimed_at IS NULL AND pledged_amount < target_amount AND deadline * 1000 >= ?`);
408413
params.push(now);
409414
break;
410415
}
@@ -469,8 +474,13 @@ if (options?.searchQuery && options.searchQuery.trim()) {
469474
const { pledge_count: _pledgeCount, ...campaignRow } = row;
470475
void _pledgeCount;
471476

472-
const now = Math.floor(Date.now() / 1000);
473-
if (campaignRow.claimed_at === null && campaignRow.pledged_amount < campaignRow.target_amount && now >= campaignRow.deadline && campaignRow.failed_at === null) {
477+
const now = nowInMilliseconds();
478+
if (
479+
campaignRow.claimed_at === null &&
480+
campaignRow.pledged_amount < campaignRow.target_amount &&
481+
now > campaignRow.deadline * 1000 &&
482+
campaignRow.failed_at === null
483+
) {
474484
campaignRow.failed_at = campaignRow.deadline;
475485
db.prepare(`UPDATE campaigns SET failed_at = ? WHERE id = ?`).run(campaignRow.deadline, campaignRow.id);
476486
}
@@ -498,8 +508,13 @@ export function getCampaign(campaignId: string): CampaignRecord | undefined {
498508
| undefined;
499509

500510
if (row) {
501-
const now = Math.floor(Date.now() / 1000);
502-
if (row.claimed_at === null && row.pledged_amount < row.target_amount && now >= row.deadline && row.failed_at === null) {
511+
const now = nowInMilliseconds();
512+
if (
513+
row.claimed_at === null &&
514+
row.pledged_amount < row.target_amount &&
515+
now > row.deadline * 1000 &&
516+
row.failed_at === null
517+
) {
503518
row.failed_at = row.deadline;
504519
db.prepare(`UPDATE campaigns SET failed_at = ? WHERE id = ?`).run(row.deadline, row.id);
505520
}
@@ -972,16 +987,16 @@ export function reconcileOnChainPledge(
972987
* @param at - Unix timestamp (seconds) used to classify campaign statuses; defaults to now.
973988
* @returns A {@link GlobalStats} object with total campaigns, per-status counts, total pledged, and unique contributor count.
974989
*/
975-
export function getGlobalStats(at = nowInSeconds()): GlobalStats {
990+
export function getGlobalStats(at = nowInMilliseconds()): GlobalStats {
976991
const db = getDb();
977992
const row = db
978993
.prepare(
979994
`SELECT
980995
COUNT(*) AS total_campaigns,
981996
SUM(CASE WHEN claimed_at IS NOT NULL THEN 1 ELSE 0 END) AS claimed_count,
982997
SUM(CASE WHEN claimed_at IS NULL AND pledged_amount >= target_amount THEN 1 ELSE 0 END) AS funded_count,
983-
SUM(CASE WHEN claimed_at IS NULL AND pledged_amount < target_amount AND deadline <= ? THEN 1 ELSE 0 END) AS failed_count,
984-
SUM(CASE WHEN claimed_at IS NULL AND pledged_amount < target_amount AND deadline > ? THEN 1 ELSE 0 END) AS open_count,
998+
SUM(CASE WHEN claimed_at IS NULL AND pledged_amount < target_amount AND deadline * 1000 < ? THEN 1 ELSE 0 END) AS failed_count,
999+
SUM(CASE WHEN claimed_at IS NULL AND pledged_amount < target_amount AND deadline * 1000 >= ? THEN 1 ELSE 0 END) AS open_count,
9851000
COALESCE(SUM(pledged_amount), 0) AS total_pledged
9861001
FROM campaigns`,
9871002
)

0 commit comments

Comments
 (0)