Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 39 additions & 1 deletion backend/src/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { Server } from 'http';
import path from 'path';
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { app } from './index';
import { initCampaignStore } from './services/campaignStore';
import { createCampaign, initCampaignStore } from './services/campaignStore';
import { getDb } from './services/db';

// Mock sorobanRpc to avoid real network calls during tests
Expand Down Expand Up @@ -409,6 +409,44 @@ describe('Campaign maxPerContributor Field', () => {
expect(campaign.maxPerContributor).toBe(50);
});

it('keeps a campaign open at the exact deadline and fails it 1ms later', async () => {
const fixedNow = 1_700_000_000_000;
const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(fixedNow);

try {
const campaign = createCampaign({
creator: CREATOR,
title: 'Exact deadline boundary campaign',
description: 'Boundary test for exact deadline status consistency',
acceptedTokens: ['USDC'],
targetAmount: 100,
deadline: Math.floor(fixedNow / 1000),
});

const firstCall = await get('/api/campaigns?page=1&limit=10');
expect(firstCall.status).toBe(200);

const firstListedCampaign = firstCall.data.data.find((item: { id: string; progress: { status: string } }) => item.id === campaign.id);
expect(firstListedCampaign?.progress.status).toBe('open');

const secondCall = await get('/api/campaigns?page=1&limit=10');
expect(secondCall.status).toBe(200);

const secondListedCampaign = secondCall.data.data.find((item: { id: string; progress: { status: string } }) => item.id === campaign.id);
expect(secondListedCampaign?.progress.status).toBe('open');

nowSpy.mockReturnValue(fixedNow + 1);

const oneMillisecondLater = await get('/api/campaigns?page=1&limit=10');
expect(oneMillisecondLater.status).toBe(200);

const failedCampaign = oneMillisecondLater.data.data.find((item: { id: string; progress: { status: string } }) => item.id === campaign.id);
expect(failedCampaign?.progress.status).toBe('failed');
} finally {
nowSpy.mockRestore();
}
});

it('includes maxPerContributor in GET /api/campaigns/:id detail response', async () => {
const now = Math.floor(Date.now() / 1000);

Expand Down
29 changes: 19 additions & 10 deletions backend/src/services/__tests__/mutation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,21 +124,30 @@ describe('calculateProgress – boundary conditions', () => {
expect(progress.canRefund).toBe(false);
});

it('is "failed" when deadline == now (at boundary)', () => {
const now = Math.floor(Date.now() / 1000);
it('is "open" at the exact deadline and fails only after it passes', () => {
const now = Date.now();
const campaign = createCampaign({
creator: CREATOR,
title: 'Boundary failed',
title: 'Boundary open',
description: 'desc',
assetCode: 'USDC',
targetAmount: 100,
deadline: now,
deadline: Math.floor(now / 1000) + 1,
});
// Evaluate exactly AT the deadline
const progress = calculateProgress(campaign, campaign.deadline);
expect(progress.status).toBe('failed');
expect(progress.canPledge).toBe(false);
expect(progress.canRefund).toBe(true);
campaign.deadline = now / 1000;

const exactBoundary = calculateProgress(campaign, now);
expect(exactBoundary.status).toBe('open');
expect(exactBoundary.canPledge).toBe(true);
expect(exactBoundary.canRefund).toBe(false);

const oneMillisecondBefore = calculateProgress(campaign, now - 1);
expect(oneMillisecondBefore.status).toBe('open');

const oneMillisecondAfter = calculateProgress(campaign, now + 1);
expect(oneMillisecondAfter.status).toBe('failed');
expect(oneMillisecondAfter.canPledge).toBe(false);
expect(oneMillisecondAfter.canRefund).toBe(true);
});

it('is "funded" when pledgedAmount exactly equals targetAmount before deadline', () => {
Expand Down Expand Up @@ -246,7 +255,7 @@ describe('calculateProgress – boundary conditions', () => {
deadline: future(3600), // 1 hour in the future
});
// Evaluate 2 hours AFTER the deadline → hoursLeft should be 0
const evalAt = campaign.deadline + 7200;
const evalAt = campaign.deadline * 1000 + 7200 * 1000;
const progress = calculateProgress(campaign, evalAt);
expect(progress.hoursLeft).toBe(0);
});
Expand Down
41 changes: 28 additions & 13 deletions backend/src/services/campaignStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,10 @@ function nowInSeconds(): number {
return Math.floor(Date.now() / 1000);
}

function nowInMilliseconds(): number {
return Date.now();
}

function round(value: number): number {
return Number(value.toFixed(2));
}
Expand Down Expand Up @@ -242,10 +246,11 @@ function checkContributorLimit(
*/
export function calculateProgress(
campaign: CampaignRecord,
at = nowInSeconds(),
at = nowInMilliseconds(),
pledgeCount?: number,
): CampaignProgress {
const deadlineReached = at >= campaign.deadline;
const deadlineAt = campaign.deadline * 1000;
const deadlineReached = at > deadlineAt;
const canClaim =
campaign.claimedAt === undefined &&
deadlineReached &&
Expand All @@ -270,7 +275,7 @@ export function calculateProgress(
percentFunded: round((campaign.pledgedAmount / campaign.targetAmount) * 100),
remainingAmount: round(Math.max(0, campaign.targetAmount - campaign.pledgedAmount)),
pledgeCount: pledgeCount ?? getActivePledgeCount(campaign.id),
hoursLeft: round(Math.max(0, campaign.deadline - at) / 3600),
hoursLeft: round(Math.max(0, deadlineAt - at) / 3600000),
canPledge,
canClaim,
canRefund,
Expand Down Expand Up @@ -389,7 +394,7 @@ if (options?.searchQuery && options.searchQuery.trim()) {
}

if (options?.status) {
const now = Math.floor(Date.now() / 1000);
const now = nowInMilliseconds();
switch (options.status) {
case 'claimed':
whereClauses.push(`claimed_at IS NOT NULL`);
Expand All @@ -399,12 +404,12 @@ if (options?.searchQuery && options.searchQuery.trim()) {
break;
case 'failed':
whereClauses.push(
`campaigns.claimed_at IS NULL AND campaigns.pledged_amount < campaigns.target_amount AND campaigns.deadline <= ?`,
`campaigns.claimed_at IS NULL AND campaigns.pledged_amount < campaigns.target_amount AND campaigns.deadline * 1000 < ?`,
);
params.push(now);
break;
case 'open':
whereClauses.push(`claimed_at IS NULL AND pledged_amount < target_amount AND deadline > ?`);
whereClauses.push(`claimed_at IS NULL AND pledged_amount < target_amount AND deadline * 1000 >= ?`);
params.push(now);
break;
}
Expand Down Expand Up @@ -469,8 +474,13 @@ if (options?.searchQuery && options.searchQuery.trim()) {
const { pledge_count: _pledgeCount, ...campaignRow } = row;
void _pledgeCount;

const now = Math.floor(Date.now() / 1000);
if (campaignRow.claimed_at === null && campaignRow.pledged_amount < campaignRow.target_amount && now >= campaignRow.deadline && campaignRow.failed_at === null) {
const now = nowInMilliseconds();
if (
campaignRow.claimed_at === null &&
campaignRow.pledged_amount < campaignRow.target_amount &&
now > campaignRow.deadline * 1000 &&
campaignRow.failed_at === null
) {
campaignRow.failed_at = campaignRow.deadline;
db.prepare(`UPDATE campaigns SET failed_at = ? WHERE id = ?`).run(campaignRow.deadline, campaignRow.id);
}
Expand Down Expand Up @@ -498,8 +508,13 @@ export function getCampaign(campaignId: string): CampaignRecord | undefined {
| undefined;

if (row) {
const now = Math.floor(Date.now() / 1000);
if (row.claimed_at === null && row.pledged_amount < row.target_amount && now >= row.deadline && row.failed_at === null) {
const now = nowInMilliseconds();
if (
row.claimed_at === null &&
row.pledged_amount < row.target_amount &&
now > row.deadline * 1000 &&
row.failed_at === null
) {
row.failed_at = row.deadline;
db.prepare(`UPDATE campaigns SET failed_at = ? WHERE id = ?`).run(row.deadline, row.id);
}
Expand Down Expand Up @@ -941,16 +956,16 @@ export function reconcileOnChainPledge(
* @param at - Unix timestamp (seconds) used to classify campaign statuses; defaults to now.
* @returns A {@link GlobalStats} object with total campaigns, per-status counts, total pledged, and unique contributor count.
*/
export function getGlobalStats(at = nowInSeconds()): GlobalStats {
export function getGlobalStats(at = nowInMilliseconds()): GlobalStats {
const db = getDb();
const row = db
.prepare(
`SELECT
COUNT(*) AS total_campaigns,
SUM(CASE WHEN claimed_at IS NOT NULL THEN 1 ELSE 0 END) AS claimed_count,
SUM(CASE WHEN claimed_at IS NULL AND pledged_amount >= target_amount THEN 1 ELSE 0 END) AS funded_count,
SUM(CASE WHEN claimed_at IS NULL AND pledged_amount < target_amount AND deadline <= ? THEN 1 ELSE 0 END) AS failed_count,
SUM(CASE WHEN claimed_at IS NULL AND pledged_amount < target_amount AND deadline > ? THEN 1 ELSE 0 END) AS open_count,
SUM(CASE WHEN claimed_at IS NULL AND pledged_amount < target_amount AND deadline * 1000 < ? THEN 1 ELSE 0 END) AS failed_count,
SUM(CASE WHEN claimed_at IS NULL AND pledged_amount < target_amount AND deadline * 1000 >= ? THEN 1 ELSE 0 END) AS open_count,
COALESCE(SUM(pledged_amount), 0) AS total_pledged
FROM campaigns`,
)
Expand Down