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
51 changes: 51 additions & 0 deletions src/app/(main)/dashboard/page.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,11 @@ vi.mock('@/components/toast/ToastProvider', () => ({
useToast: () => ({ showToast: vi.fn() }),
}));

vi.mock('@/lib/stellar', () => ({
getNativeAssetAddress: () => 'CNATIVE',
getUsdcAssetAddress: () => 'CUSDC',
}));


vi.mock('@/components/wallet/WalletProvider', () => ({
useWallet: vi.fn(),
Expand Down Expand Up @@ -437,6 +442,52 @@ describe('DashboardPage', () => {
});
});

describe('multi-token totals', () => {
it('shows each token committed separately and never sums them into one number', async () => {
vi.spyOn(WalletProviderMod, 'useWallet').mockReturnValue({
address: 'G123',
connecting: false,
connect: vi.fn(),
disconnect: vi.fn(),
signTransaction: vi.fn(),
signMessage: vi.fn(),
});

// One XLM stream: balance 3, withdrawn 1 → 4 XLM committed.
// One USDC stream: balance 2, withdrawn 8 → 10 USDC committed.
// The two totals must appear separately, never added together.
vi.spyOn(ApiMod, 'getStreams').mockResolvedValue([
makeStream({
id: 's1',
tokenAddress: 'CNATIVE',
balance: '30000000',
withdrawn: '10000000',
status: 'ACTIVE',
ngo: { id: 'ngo-1', name: 'XLM NGO', ownerAddress: 'G1' },
}),
makeStream({
id: 's2',
tokenAddress: 'CUSDC',
balance: '20000000',
withdrawn: '80000000',
status: 'ACTIVE',
ngo: { id: 'ngo-2', name: 'USDC NGO', ownerAddress: 'G2' },
}),
]);

render(<DashboardPage />);

await waitFor(() => expect(screen.getByText('Total committed')).toBeInTheDocument());

const committedCell = screen.getByText('Total committed').closest('div')!;
// Each token labelled separately.
expect(committedCell).toHaveTextContent('4 XLM');
expect(committedCell).toHaveTextContent('10 USDC');
// Must NOT show a bare cross-token sum like "14".
expect(committedCell).not.toHaveTextContent('14');
});
});

describe('background polling', () => {
it('re-fetches streams every 30 seconds while the tab is visible', async () => {
vi.useFakeTimers();
Expand Down
35 changes: 35 additions & 0 deletions src/app/(main)/ngo-admin/page.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -148,5 +148,40 @@ describe('NgoAdminPage states', () => {
expect(streamList).toBeInTheDocument();
expect(streamList).toHaveTextContent('Streams count: 2');
});

it('groups totals by token when streams use two different tokens', async () => {
const streams = [
makeStream({
id: 's1',
tokenAddress: 'CNATIVE',
balance: '30000000', // 3 XLM
withdrawn: '10000000', // 1 XLM
status: 'ACTIVE',
}),
makeStream({
id: 's2',
tokenAddress: 'CUSDC',
balance: '20000000', // 2 USDC
withdrawn: '50000000', // 5 USDC
status: 'ACTIVE',
}),
];
getStreams.mockResolvedValue(streams);

render(<NgoAdminPage />);

await screen.findByText('Managing streams for Test NGO.');

// Total withdrawn must show both tokens separately, never summed together.
// 1 XLM withdrawn + 5 USDC withdrawn → two labelled figures.
const withdrawnCell = screen.getByText('Total withdrawn').closest('div')!;
expect(withdrawnCell).toHaveTextContent('1 XLM');
expect(withdrawnCell).toHaveTextContent('5 USDC');

// Remaining balance (both ACTIVE): 3 XLM + 2 USDC.
const balanceCell = screen.getByText('Remaining balance').closest('div')!;
expect(balanceCell).toHaveTextContent('3 XLM');
expect(balanceCell).toHaveTextContent('2 USDC');
});
});

14 changes: 8 additions & 6 deletions src/app/(main)/platform-admin/page.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@ vi.mock('@/lib/ngoRegistryClient', () => ({
revoke_ngo: revokeNgoMock,
},
ready: true,
initError: null,
retryInit: vi.fn(),
}),
}));

Expand All @@ -74,12 +76,12 @@ describe('PlatformAdminPage', () => {
beforeEach(() => {
vi.clearAllMocks();
getNgos.mockResolvedValue([]);
listNgoApplications.mockResolvedValue([]);
listNgoApplications.mockResolvedValue({ applications: [], total: 0, limit: 100, offset: 0 });
});

describe('approve flow', () => {
it('approves an application on-chain and off-chain, shows success toast, and refreshes', async () => {
listNgoApplications.mockResolvedValueOnce([APPLICATION]).mockResolvedValueOnce([]);
listNgoApplications.mockResolvedValueOnce({ applications: [APPLICATION], total: 1, limit: 100, offset: 0 }).mockResolvedValueOnce({ applications: [], total: 0, limit: 100, offset: 0 });
const signAndSend = vi.fn().mockResolvedValue({});
approveNgoMock.mockResolvedValue({ signAndSend, built: { fee: '100' } });
reviewNgoApplication.mockResolvedValue({});
Expand All @@ -104,7 +106,7 @@ describe('PlatformAdminPage', () => {
});

it('shows error toast when on-chain approval fails and skips off-chain review', async () => {
listNgoApplications.mockResolvedValueOnce([APPLICATION]);
listNgoApplications.mockResolvedValueOnce({ applications: [APPLICATION], total: 1, limit: 100, offset: 0 });
approveNgoMock.mockRejectedValue(new Error('On-chain approval rejected'));

const user = userEvent.setup();
Expand All @@ -120,7 +122,7 @@ describe('PlatformAdminPage', () => {

describe('reject flow', () => {
it('asks for confirmation, then calls reviewNgoApplication with reject, shows toast and refreshes', async () => {
listNgoApplications.mockResolvedValueOnce([APPLICATION]).mockResolvedValueOnce([]);
listNgoApplications.mockResolvedValueOnce({ applications: [APPLICATION], total: 1, limit: 100, offset: 0 }).mockResolvedValueOnce({ applications: [], total: 0, limit: 100, offset: 0 });
const user = userEvent.setup();

render(<PlatformAdminPage />);
Expand All @@ -143,7 +145,7 @@ describe('PlatformAdminPage', () => {
});

it('cancels rejection when clicking Never mind', async () => {
listNgoApplications.mockResolvedValueOnce([APPLICATION]);
listNgoApplications.mockResolvedValueOnce({ applications: [APPLICATION], total: 1, limit: 100, offset: 0 });
const user = userEvent.setup();

render(<PlatformAdminPage />);
Expand All @@ -158,7 +160,7 @@ describe('PlatformAdminPage', () => {
});

it('shows error toast when reject review fails', async () => {
listNgoApplications.mockResolvedValueOnce([APPLICATION]);
listNgoApplications.mockResolvedValueOnce({ applications: [APPLICATION], total: 1, limit: 100, offset: 0 });
reviewNgoApplication.mockRejectedValue(new Error('API reject failed'));

const user = userEvent.setup();
Expand Down
96 changes: 90 additions & 6 deletions src/app/(main)/platform-admin/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,15 @@ import { useLatestRequest } from '@/lib/useLatestRequest';

export default function PlatformAdminPage() {
const { address, signMessage } = useWallet();
const { client, ready } = useNgoRegistryClient();
const { client, ready, initError, retryInit } = useNgoRegistryClient();
const { showToast } = useToast();
const [applications, setApplications] = useState<NgoApplication[]>([]);
const [totalApplications, setTotalApplications] = useState(0);
const [verifiedNgos, setVerifiedNgos] = useState<Ngo[]>([]);
// Starts true so the "no applications" state isn't painted before the
// first fetch begins.
const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [error, setError] = useState<string | null>(null);
const [busyId, setBusyId] = useState<string | null>(null);
const [confirmingRejectId, setConfirmingRejectId] = useState<string | null>(null);
Expand Down Expand Up @@ -52,12 +54,13 @@ export default function PlatformAdminPage() {
setLoading(true);
setError(null);
try {
const [apps, ngos] = await Promise.all([
const [page, ngos] = await Promise.all([
listNgoApplications(address, signMessage, statusFilter),
getNgos(request.signal),
]);
if (!request.isCurrent()) return;
setApplications(apps);
setApplications(page.applications);
setTotalApplications(page.total);
setVerifiedNgos(ngos.filter(n => n.verified));
} catch (err) {
if (!request.isCurrent()) return;
Expand All @@ -81,6 +84,23 @@ export default function PlatformAdminPage() {
void refresh();
}, [refresh]);

async function handleLoadMore(): Promise<void> {
if (!address) return;
setLoadingMore(true);
try {
const page = await listNgoApplications(address, signMessage, statusFilter, applications.length);
// Append — don't replace — so rows already visible stay put.
setApplications((prev) => [...prev, ...page.applications]);
setTotalApplications(page.total);
} catch (err) {
showToast('error', err instanceof Error ? err.message : 'Failed to load more applications.');
} finally {
setLoadingMore(false);
}
}

const [retryApprovingId, setRetryApprovingId] = useState<string | null>(null);

// Builds the (unsigned) approve tx and shows its fee estimate so the
// admin can confirm before anything is signed. Building just simulates
// the call — it doesn't touch the wallet or submit anything.
Expand All @@ -106,21 +126,38 @@ export default function PlatformAdminPage() {
}

async function handleConfirmApprove(app: NgoApplication): Promise<void> {
if (!address || !pendingApproveTx) return;
const isRetry = retryApprovingId === app.id;
if (!address || (!isRetry && !pendingApproveTx)) return;
setBusyId(app.id);
let onChainDone = isRetry;
try {
// On-chain first: this is what actually flips Ngo.verified once the
// indexer picks up the resulting event. If the wallet rejects or the
// tx fails, we deliberately haven't touched the off-chain review
// status yet — better an application stuck "pending" than one
// marked "approved" while the NGO is still unverified on-chain.
await pendingApproveTx.signAndSend();
if (!isRetry) {
await pendingApproveTx!.signAndSend();
onChainDone = true;
}

// Now mark off-chain. If this fails, the NGO is already verified on-chain
// but the app is still PENDING — surface a targeted retry action.
await reviewNgoApplication(address, signMessage, app.id, 'approve', reviewNotes[app.id]);
showToast('success', `${app.name} approved.`);
setRetryApprovingId(null);
await refresh();
} catch (err) {
showToast('error', err instanceof Error ? err.message : 'Something went wrong.');
const message = err instanceof Error ? err.message : 'Something went wrong.';
// Once the on-chain approval has gone through, any failure is in the
// off-chain marking — show the retry-marking-approved action.
if (onChainDone) {
showToast('error', `Couldn't mark as approved: ${message}`);
setRetryApprovingId(app.id);
} else {
showToast('error', message);
setRetryApprovingId(null);
}
} finally {
setBusyId(null);
setConfirmingApproveId(null);
Expand All @@ -129,6 +166,10 @@ export default function PlatformAdminPage() {
}
}

function handleRetryMarkApproved(app: NgoApplication): void {
void handleConfirmApprove(app);
}

async function handleReject(app: NgoApplication): Promise<void> {
if (!address) return;
setBusyId(app.id);
Expand Down Expand Up @@ -173,6 +214,19 @@ export default function PlatformAdminPage() {
<ConnectWalletPrompt className="mt-8" message="Connect the platform admin wallet." />
)}

{address && initError && (
<div className="mt-8">
<p className="text-red-600 dark:text-red-400">{initError}</p>
<button
type="button"
onClick={retryInit}
className="mt-3 rounded-md border border-gray-300 px-4 py-2 text-sm font-medium hover:bg-gray-50 dark:border-gray-700 dark:hover:bg-gray-800"
>
Retry
</button>
</div>
)}

{address && (
<div className="mt-8 border-b border-gray-200 dark:border-gray-800">
<div role="tablist" aria-label="Application status" className="-mb-px flex space-x-8">
Expand Down Expand Up @@ -325,6 +379,21 @@ export default function PlatformAdminPage() {
</>
)}
</div>
{retryApprovingId === app.id && (
<div className="flex flex-wrap items-center justify-end gap-2 rounded-md bg-amber-50 p-2 dark:bg-amber-900/20">
<span className="text-xs text-amber-900 dark:text-amber-200">
NGO verified on-chain but couldn't mark approved in the system.
</span>
<button
type="button"
onClick={() => void handleRetryMarkApproved(app)}
disabled={busyId === app.id}
className="rounded bg-amber-600 px-2 py-1 text-xs font-medium text-white hover:bg-amber-700 disabled:opacity-50 dark:bg-amber-500 dark:hover:bg-amber-600"
>
{busyId === app.id ? 'Retrying…' : 'Retry marking approved'}
</button>
</div>
)}
</>
) : (
<div>
Expand All @@ -343,6 +412,21 @@ export default function PlatformAdminPage() {
</ul>
)}

{address && !loading && applications.length > 0 && applications.length < totalApplications && (
<div className="mt-6 flex justify-center">
<button
type="button"
onClick={() => void handleLoadMore()}
disabled={loadingMore}
className="rounded-md border border-gray-300 px-4 py-2 text-sm font-medium hover:bg-gray-50 disabled:opacity-50 dark:border-gray-700 dark:hover:bg-gray-800"
>
{loadingMore
? 'Loading…'
: `Load more (${applications.length} of ${totalApplications})`}
</button>
</div>
)}

{address && !loading && !error && (
<div className="mt-16">
<h2 className="text-xl font-bold">Verified NGOs</h2>
Expand Down
45 changes: 44 additions & 1 deletion src/lib/adminApi.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,50 @@ describe('listNgoApplications', () => {
);

const result = await listNgoApplications(ADDRESS, signMessage);
expect(result).toEqual([application]);
expect(result.applications).toEqual([application]);
expect(result.total).toBe(1);
expect(result.limit).toBe(100);
expect(result.offset).toBe(0);
});

it('passes offset as a query param when provided', async () => {
const { listNgoApplications } = await freshAdminApi();
const signMessage = vi.fn().mockResolvedValue('sig');
const fetchMock = vi.fn(() =>
Promise.resolve(
new Response(
JSON.stringify({ applications: [], total: 50, limit: 20, offset: 20 }),
{ status: 200, headers: { 'content-type': 'application/json' } },
),
),
);
vi.stubGlobal('fetch', fetchMock);

const result = await listNgoApplications(ADDRESS, signMessage, 'PENDING', 20);

const calledUrl = (fetchMock.mock.calls[0][0] as string);
expect(calledUrl).toContain('offset=20');
expect(result.total).toBe(50);
expect(result.offset).toBe(20);
});

it('omits offset query param when offset is 0', async () => {
const { listNgoApplications } = await freshAdminApi();
const signMessage = vi.fn().mockResolvedValue('sig');
const fetchMock = vi.fn(() =>
Promise.resolve(
new Response(
JSON.stringify({ applications: [], total: 0, limit: 100, offset: 0 }),
{ status: 200, headers: { 'content-type': 'application/json' } },
),
),
);
vi.stubGlobal('fetch', fetchMock);

await listNgoApplications(ADDRESS, signMessage, 'PENDING', 0);

const calledUrl = (fetchMock.mock.calls[0][0] as string);
expect(calledUrl).not.toContain('offset=');
});

it('includes the status query param when provided', async () => {
Expand Down
Loading
Loading