diff --git a/backend/package.json b/backend/package.json index cca340dd..b8406e93 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,57 +1,113 @@ -{ - "name": "pulsartrack-backend", - "version": "1.0.0", - "description": "PulsarTrack backend API server with Stellar Horizon integration", - "main": "dist/index.js", - "scripts": { - "dev": "nodemon --exec ts-node src/index.ts", - "build": "tsc", - "start": "node dist/index.js", - "test": "vitest --run", - "test:coverage": "vitest --coverage", - "test:watch": "vitest", - "typecheck": "tsc --noEmit", - "audit": "npm audit --audit-level=high" - }, - "keywords": [], - "author": "", - "license": "ISC", - "dependencies": { - "@prisma/client": "^5.22.0", - "@sentry/node": "^10.72.0", - "@stellar/stellar-sdk": "^14.5.0", - "cors": "^2.8.6", - "date-fns": "^4.1.0", - "dotenv": "^17.3.1", - "express": "^5.2.1", - "helmet": "^8.1.0", - "ioredis": "^5.9.3", - "morgan": "^1.10.1", - "pg": "^8.18.0", - "pino": "^10.3.1", - "pino-http": "^11.0.0", - "pino-pretty": "^13.1.3", - "rate-limiter-flexible": "^9.1.1", - "redis": "^5.11.0", - "uuid": "^13.0.0", - "ws": "^8.19.0" - }, - "devDependencies": { - "@types/cors": "^2.8.19", - "@types/express": "^5.0.6", - "@types/morgan": "^1.9.10", - "@types/node": "^25.2.3", - "@types/pg": "^8.16.0", - "@types/pino-http": "^5.8.4", - "@types/supertest": "^6.0.3", - "@types/uuid": "^10.0.0", - "@types/ws": "^8.18.1", - "@vitest/coverage-v8": "^4.0.18", - "nodemon": "^3.1.11", - "prisma": "^5.22.0", - "supertest": "^7.2.2", - "ts-node": "^10.9.2", - "typescript": "^5.9.3", - "vitest": "^4.0.18" - } -} +{ + "name": "pulsartrack-backend", + "version": "1.0.0", + "description": "PulsarTrack backend API server with Stellar Horizon integration", + "main": "dist/index.js", + "scripts": { + "dev": "nodemon --exec ts-node src/index.ts", + "build": "tsc", + "start": "node dist/index.js", + "test": "vitest --run", + "test:coverage": "vitest --coverage", + "test:watch": "vitest", + "typecheck": "tsc --noEmit", + "audit": "npm audit --audit-level=high" + }, + "keywords": [], + "author": "", + "license": "Apache-2.0", + "dependencies": { + "@prisma/client": "^5.22.0", + "@stellar/stellar-sdk": "^14.5.0", + "cors": "^2.8.6", + "date-fns": "^4.1.0", + "dotenv": "^17.3.1", + "express": "^5.2.1", + "helmet": "^8.1.0", + "ioredis": "^5.9.3", + "morgan": "^1.10.1", + "pg": "^8.18.0", + "pino": "^10.3.1", + "pino-http": "^11.0.0", + "pino-pretty": "^13.1.3", + "rate-limiter-flexible": "^9.1.1", + "redis": "^5.11.0", + "uuid": "^13.0.0", + "ws": "^8.19.0" + }, + "devDependencies": { + "@types/cors": "^2.8.19", + "@types/express": "^5.0.6", + "@types/morgan": "^1.9.10", + "@types/node": "^25.2.3", + "@types/pg": "^8.16.0", + "@types/pino-http": "^5.8.4", + "@types/supertest": "^6.0.3", + "@types/uuid": "^10.0.0", + "@types/ws": "^8.18.1", + "@vitest/coverage-v8": "^4.0.18", + "nodemon": "^3.1.11", + "prisma": "^5.22.0", + "supertest": "^7.2.2", + "ts-node": "^10.9.2", + "typescript": "^5.9.3", + "vitest": "^4.0.18" + } +} +{ + "name": "pulsartrack-backend", + "version": "1.0.0", + "description": "PulsarTrack backend API server with Stellar Horizon integration", + "main": "dist/index.js", + "scripts": { + "dev": "nodemon --exec ts-node src/index.ts", + "build": "tsc", + "start": "node dist/index.js", + "test": "vitest --run", + "test:coverage": "vitest --coverage", + "test:watch": "vitest", + "typecheck": "tsc --noEmit", + "audit": "npm audit --audit-level=high" + }, + "keywords": [], + "author": "", + "license": "ISC", + "dependencies": { + "@prisma/client": "^5.22.0", + "@sentry/node": "^10.72.0", + "@stellar/stellar-sdk": "^14.5.0", + "cors": "^2.8.6", + "date-fns": "^4.1.0", + "dotenv": "^17.3.1", + "express": "^5.2.1", + "helmet": "^8.1.0", + "ioredis": "^5.9.3", + "morgan": "^1.10.1", + "pg": "^8.18.0", + "pino": "^10.3.1", + "pino-http": "^11.0.0", + "pino-pretty": "^13.1.3", + "rate-limiter-flexible": "^9.1.1", + "redis": "^5.11.0", + "uuid": "^13.0.0", + "ws": "^8.19.0" + }, + "devDependencies": { + "@types/cors": "^2.8.19", + "@types/express": "^5.0.6", + "@types/morgan": "^1.9.10", + "@types/node": "^25.2.3", + "@types/pg": "^8.16.0", + "@types/pino-http": "^5.8.4", + "@types/supertest": "^6.0.3", + "@types/uuid": "^10.0.0", + "@types/ws": "^8.18.1", + "@vitest/coverage-v8": "^4.0.18", + "nodemon": "^3.1.11", + "prisma": "^5.22.0", + "supertest": "^7.2.2", + "ts-node": "^10.9.2", + "typescript": "^5.9.3", + "vitest": "^4.0.18" + } +} diff --git a/backend/src/__tests__/repositories.subscriptions.test.ts b/backend/src/__tests__/repositories.subscriptions.test.ts index a3253a1b..a96c76a2 100644 --- a/backend/src/__tests__/repositories.subscriptions.test.ts +++ b/backend/src/__tests__/repositories.subscriptions.test.ts @@ -12,7 +12,7 @@ describe('subscriptions repository', () => { beforeEach(() => vi.clearAllMocks()); describe('findBySubscriber', () => { - it('returns every subscription for the subscriber, newest first', async () => { + it('returns subscriptions for the subscriber, newest first, with the default cap', async () => { mockQuery.mockResolvedValue({ rows: [{ id: 1 }, { id: 2 }] }); const result = await subscriptions.findBySubscriber('GSUB'); @@ -20,7 +20,19 @@ describe('subscriptions repository', () => { expect(result).toHaveLength(2); expect(mockQuery).toHaveBeenCalledWith( expect.stringContaining('ORDER BY started_at DESC'), - ['GSUB'], + ['GSUB', subscriptions.DEFAULT_SUBSCRIPTION_HISTORY_LIMIT], + ); + expect(mockQuery.mock.calls[0][0]).toContain('LIMIT $2'); + }); + + it('accepts an explicit limit and caps oversized requests', async () => { + mockQuery.mockResolvedValue({ rows: [] }); + + await subscriptions.findBySubscriber('GSUB', 500); + + expect(mockQuery).toHaveBeenCalledWith( + expect.stringContaining('LIMIT $2'), + ['GSUB', subscriptions.MAX_SUBSCRIPTION_HISTORY_LIMIT], ); }); @@ -78,4 +90,4 @@ describe('subscriptions repository', () => { ); }); }); -}); +}); \ No newline at end of file diff --git a/backend/src/db/repositories/subscriptions.ts b/backend/src/db/repositories/subscriptions.ts index 09a0eed9..4cef0772 100644 --- a/backend/src/db/repositories/subscriptions.ts +++ b/backend/src/db/repositories/subscriptions.ts @@ -1,5 +1,8 @@ import pool from '../../config/database'; +export const DEFAULT_SUBSCRIPTION_HISTORY_LIMIT = 50; +export const MAX_SUBSCRIPTION_HISTORY_LIMIT = 100; + interface SubscriptionRow { id: string; subscriber: string; @@ -12,12 +15,24 @@ interface SubscriptionRow { tx_hash: string | null; } -export async function findBySubscriber(subscriber: string): Promise { +function normalizeSubscriptionLimit(take = DEFAULT_SUBSCRIPTION_HISTORY_LIMIT): number { + if (!Number.isFinite(take) || take <= 0) { + return DEFAULT_SUBSCRIPTION_HISTORY_LIMIT; + } + return Math.min(Math.floor(take), MAX_SUBSCRIPTION_HISTORY_LIMIT); +} + +export async function findBySubscriber( + subscriber: string, + take = DEFAULT_SUBSCRIPTION_HISTORY_LIMIT, +): Promise { + const limit = normalizeSubscriptionLimit(take); const { rows } = await pool.query( `SELECT * FROM subscriptions WHERE subscriber = $1 - ORDER BY started_at DESC`, - [subscriber], + ORDER BY started_at DESC + LIMIT $2`, + [subscriber, limit], ); return rows; } @@ -59,4 +74,4 @@ export async function create(data: { ], ); return rows[0]; -} +} \ No newline at end of file diff --git a/frontend/src/components/campaign/CampaignForm.test.tsx b/frontend/src/components/campaign/CampaignForm.test.tsx index af039d96..b0ee7935 100644 --- a/frontend/src/components/campaign/CampaignForm.test.tsx +++ b/frontend/src/components/campaign/CampaignForm.test.tsx @@ -1,96 +1,116 @@ -import { render, screen, fireEvent, waitFor } from '@testing-library/react'; -import { CampaignForm, parseCampaignSubmission } from './CampaignForm'; -import { vi, describe, it, expect, beforeEach } from 'vitest'; -import { useCreateCampaign } from '@/hooks/useContract'; - -// Mock the hook -vi.mock('@/hooks/useContract', () => ({ - useCreateCampaign: vi.fn(), -})); - -describe('CampaignForm', () => { - const mockCreateCampaign = vi.fn(); - const mockOnSuccess = vi.fn(); - - beforeEach(() => { - vi.clearAllMocks(); - vi.mocked(useCreateCampaign).mockReturnValue({ - createCampaign: mockCreateCampaign, - isPending: false, - } as Partial> as ReturnType); - }); - - it('should show error if title is missing', async () => { - render(); - - const submitButton = screen.getByText('Create Campaign'); - fireEvent.click(submitButton); - - expect(await screen.findByText(/Title is required/i)).toBeInTheDocument(); - expect(mockCreateCampaign).not.toHaveBeenCalled(); - }); - - it('should call createCampaign with correct parameters on valid submission', async () => { - render(); - - fireEvent.change(screen.getByLabelText(/Campaign Title/i), { - target: { value: 'Test Campaign' }, - }); - fireEvent.change(screen.getByLabelText(/Content ID/i), { - target: { value: 'ipfs://123' }, - }); - fireEvent.change(screen.getByLabelText(/Total Budget/i), { - target: { value: '100' }, - }); - - mockCreateCampaign.mockResolvedValue({ success: true, result: 1 }); - - fireEvent.click(screen.getByText('Create Campaign')); - - await waitFor(() => { - expect(mockCreateCampaign).toHaveBeenCalledWith(expect.objectContaining({ - title: 'Test Campaign', - contentId: 'ipfs://123', - budgetXlm: 100, - })); - expect(mockOnSuccess).toHaveBeenCalledWith(1); - }); - }); - - it('should reject non-finite numeric values before contract submission', () => { - expect(parseCampaignSubmission({ - title: 'Overflow Campaign', - contentId: 'ipfs://overflow', +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { CampaignForm, parseCampaignSubmission } from './CampaignForm'; +import { vi, describe, it, expect, beforeEach } from 'vitest'; +import { useCreateCampaign } from '@/hooks/useContract'; +import { campaignSchema } from '@/lib/validation/schemas'; + +// Mock the hook +vi.mock('@/hooks/useContract', () => ({ + useCreateCampaign: vi.fn(), +})); + +describe('CampaignForm', () => { + const mockCreateCampaign = vi.fn(); + const mockOnSuccess = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(useCreateCampaign).mockReturnValue({ + createCampaign: mockCreateCampaign, + isPending: false, + } as Partial> as ReturnType); + }); + + it('should show error if title is missing', async () => { + render(); + + const submitButton = screen.getByText('Create Campaign'); + fireEvent.click(submitButton); + + expect(await screen.findByText(/Title is required/i)).toBeInTheDocument(); + expect(mockCreateCampaign).not.toHaveBeenCalled(); + }); + + it('should reject campaign titles longer than the backend limit', () => { + const result = campaignSchema.safeParse({ + title: 'x'.repeat(201), + contentId: 'ipfs://123', campaignType: 1, - budgetXlm: '1e309', - costPerViewXlm: '0.001', + budgetXlm: '100', + costPerViewXlm: '0.01', durationDays: 30, - targetViews: '10000', - dailyViewLimit: '1000', + targetViews: '100', + dailyViewLimit: '10', refundable: true, - })).toEqual({ - ok: false, - error: 'Invalid numeric values', - }); - }); - - it('should handle submission error', async () => { - mockCreateCampaign.mockRejectedValue(new Error('Contract call failed')); - - render(); - - fireEvent.change(screen.getByLabelText(/Campaign Title/i), { - target: { value: 'Error Campaign' }, - }); - fireEvent.change(screen.getByLabelText(/Content ID/i), { - target: { value: 'error' }, }); - fireEvent.change(screen.getByLabelText(/Total Budget/i), { - target: { value: '10' }, - }); - - fireEvent.click(screen.getByText('Create Campaign')); - expect(await screen.findByText(/Contract call failed/i)).toBeInTheDocument(); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0].message).toBe('Title must be 200 characters or less'); + } }); -}); + + it('should call createCampaign with correct parameters on valid submission', async () => { + render(); + + fireEvent.change(screen.getByLabelText(/Campaign Title/i), { + target: { value: 'Test Campaign' }, + }); + fireEvent.change(screen.getByLabelText(/Content ID/i), { + target: { value: 'ipfs://123' }, + }); + fireEvent.change(screen.getByLabelText(/Total Budget/i), { + target: { value: '100' }, + }); + + mockCreateCampaign.mockResolvedValue({ success: true, result: 1 }); + + fireEvent.click(screen.getByText('Create Campaign')); + + await waitFor(() => { + expect(mockCreateCampaign).toHaveBeenCalledWith(expect.objectContaining({ + title: 'Test Campaign', + contentId: 'ipfs://123', + budgetXlm: 100, + })); + expect(mockOnSuccess).toHaveBeenCalledWith(1); + }); + }); + + it('should reject non-finite numeric values before contract submission', () => { + expect(parseCampaignSubmission({ + title: 'Overflow Campaign', + contentId: 'ipfs://overflow', + campaignType: 1, + budgetXlm: '1e309', + costPerViewXlm: '0.001', + durationDays: 30, + targetViews: '10000', + dailyViewLimit: '1000', + refundable: true, + })).toEqual({ + ok: false, + error: 'Invalid numeric values', + }); + }); + + it('should handle submission error', async () => { + mockCreateCampaign.mockRejectedValue(new Error('Contract call failed')); + + render(); + + fireEvent.change(screen.getByLabelText(/Campaign Title/i), { + target: { value: 'Error Campaign' }, + }); + fireEvent.change(screen.getByLabelText(/Content ID/i), { + target: { value: 'error' }, + }); + fireEvent.change(screen.getByLabelText(/Total Budget/i), { + target: { value: '10' }, + }); + + fireEvent.click(screen.getByText('Create Campaign')); + + expect(await screen.findByText(/Contract call failed/i)).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/hooks/useTxNotifications.test.tsx b/frontend/src/hooks/useTxNotifications.test.tsx index adbd9323..e7cd7a55 100644 --- a/frontend/src/hooks/useTxNotifications.test.tsx +++ b/frontend/src/hooks/useTxNotifications.test.tsx @@ -1,12 +1,13 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { renderHook } from '@testing-library/react'; -import React from 'react'; +import type { Transaction } from '../store/tx-store'; -// Shared mutable state for the mock store -let currentTransactions: any[] = []; +const storeState = vi.hoisted(() => ({ + transactions: [] as Transaction[], +})); vi.mock('../store/tx-store', () => ({ - useTransactionStore: (() => ({ transactions: currentTransactions })) as any, + useTransactionStore: () => ({ transactions: storeState.transactions }), })); const { mockSuccess, mockError } = vi.hoisted(() => ({ @@ -28,9 +29,19 @@ vi.mock('../contexts/ToastContext', () => ({ import { useTxNotifications } from './useTxNotifications'; import { useToast } from '../contexts/ToastContext'; +function transaction(txHash: string, status: Transaction['status'], description: string): Transaction { + return { + txHash, + status, + description, + type: 'other', + timestamp: Date.now(), + }; +} + beforeEach(() => { vi.clearAllMocks(); - currentTransactions = []; + storeState.transactions = []; }); describe('useTxNotifications', () => { @@ -39,43 +50,31 @@ describe('useTxNotifications', () => { expect(useToast).toHaveBeenCalled(); }); - it('shows success toast on pending→success transition', () => { - // Start with pending transaction - currentTransactions = [ - { txHash: 'h1', status: 'pending', description: 'Lock', timestamp: Date.now() }, - ]; + it('shows success toast on pending to success transition', () => { + storeState.transactions = [transaction('h1', 'pending', 'Lock')]; const { rerender } = renderHook(() => useTxNotifications()); - // Transition to success — must use new array reference for React to detect change - currentTransactions = [ - { txHash: 'h1', status: 'success', description: 'Lock', timestamp: Date.now() }, - ]; + storeState.transactions = [transaction('h1', 'success', 'Lock')]; rerender(); expect(mockSuccess).toHaveBeenCalledWith('Transaction completed', 'Lock'); }); - it('shows error toast on pending→failed transition', () => { - currentTransactions = [ - { txHash: 'h2', status: 'pending', description: 'Unlock', timestamp: Date.now() }, - ]; + it('shows error toast on pending to failed transition', () => { + storeState.transactions = [transaction('h2', 'pending', 'Unlock')]; const { rerender } = renderHook(() => useTxNotifications()); - currentTransactions = [ - { txHash: 'h2', status: 'failed', description: 'Unlock', timestamp: Date.now() }, - ]; + storeState.transactions = [transaction('h2', 'failed', 'Unlock')]; rerender(); expect(mockError).toHaveBeenCalledWith('Transaction failed', 'Unlock'); }); it('does not notify for pending-only transactions', () => { - currentTransactions = [ - { txHash: 'h3', status: 'pending', description: 'Vote', timestamp: Date.now() }, - ]; + storeState.transactions = [transaction('h3', 'pending', 'Vote')]; renderHook(() => useTxNotifications()); expect(mockSuccess).not.toHaveBeenCalled(); expect(mockError).not.toHaveBeenCalled(); }); -}); +}); \ No newline at end of file diff --git a/frontend/src/lib/tx-recovery.test.ts b/frontend/src/lib/tx-recovery.test.ts index e15236fb..fc1c9e6b 100644 --- a/frontend/src/lib/tx-recovery.test.ts +++ b/frontend/src/lib/tx-recovery.test.ts @@ -1,152 +1,168 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; - -const { mockGetTransaction, mockUpdateTransaction } = vi.hoisted(() => ({ - mockGetTransaction: vi.fn(), - mockUpdateTransaction: vi.fn(), -})); - -vi.mock('./soroban-client', () => ({ - getSorobanServer: vi.fn().mockResolvedValue({ getTransaction: mockGetTransaction }), -})); - -vi.mock('../store/tx-store', () => ({ - useTransactionStore: { - getState: vi.fn().mockReturnValue({ - transactions: [], - updateTransaction: mockUpdateTransaction, - }), - }, -})); - -vi.mock('@stellar/stellar-sdk', () => ({ - rpc: { - Api: { - GetTransactionStatus: { - SUCCESS: 'SUCCESS', - FAILED: 'FAILED', - NOT_FOUND: 'NOT_FOUND', - }, - }, - }, -})); - -import { checkPendingTransactions, pollTransaction } from './tx-recovery'; -import { useTransactionStore } from '../store/tx-store'; - -beforeEach(() => { - vi.clearAllMocks(); - vi.mocked(useTransactionStore.getState).mockReturnValue({ - transactions: [], - updateTransaction: mockUpdateTransaction, - } as any); -}); - -describe('checkPendingTransactions', () => { - it('does nothing when no pending transactions', async () => { - vi.mocked(useTransactionStore.getState).mockReturnValue({ - transactions: [{ txHash: 'h1', status: 'success' }], - updateTransaction: mockUpdateTransaction, - } as any); - - await checkPendingTransactions(); - expect(mockGetTransaction).not.toHaveBeenCalled(); - }); - - it('marks transaction as success when confirmed', async () => { - vi.mocked(useTransactionStore.getState).mockReturnValue({ - transactions: [{ txHash: 'h1', status: 'pending', timestamp: Date.now(), description: 'test' }], - updateTransaction: mockUpdateTransaction, - } as any); - - mockGetTransaction.mockResolvedValue({ - status: 'SUCCESS', - returnValue: 'result_val', - }); - - await checkPendingTransactions(); - - expect(mockUpdateTransaction).toHaveBeenCalledWith('h1', { - status: 'success', - result: 'result_val', - }); - }); - - it('marks transaction as failed when on-chain fails', async () => { - vi.mocked(useTransactionStore.getState).mockReturnValue({ - transactions: [{ txHash: 'h2', status: 'pending', timestamp: Date.now(), description: 'test' }], - updateTransaction: mockUpdateTransaction, - } as any); - - mockGetTransaction.mockResolvedValue({ status: 'FAILED' }); - - await checkPendingTransactions(); - - expect(mockUpdateTransaction).toHaveBeenCalledWith('h2', { - status: 'failed', - error: 'Transaction failed on-chain', - }); - }); - - it('marks old not-found transaction as failed', async () => { - const oneDayAgo = Date.now() - 25 * 60 * 60 * 1000; - vi.mocked(useTransactionStore.getState).mockReturnValue({ - transactions: [{ txHash: 'h3', status: 'timeout', timestamp: oneDayAgo, description: 'test' }], - updateTransaction: mockUpdateTransaction, - } as any); - - mockGetTransaction.mockResolvedValue({ status: 'NOT_FOUND' }); - - await checkPendingTransactions(); - - expect(mockUpdateTransaction).toHaveBeenCalledWith('h3', { - status: 'failed', - error: 'Transaction not found (may have expired)', - }); - }); - - it('handles RPC errors gracefully', async () => { - vi.mocked(useTransactionStore.getState).mockReturnValue({ - transactions: [{ txHash: 'h4', status: 'pending', timestamp: Date.now(), description: 'test' }], - updateTransaction: mockUpdateTransaction, - } as any); - - mockGetTransaction.mockRejectedValue(new Error('network error')); - - await checkPendingTransactions(); - - expect(mockUpdateTransaction).not.toHaveBeenCalled(); - }); -}); - -describe('pollTransaction', () => { - it('returns success when transaction confirmed', async () => { - mockGetTransaction.mockResolvedValue({ - status: 'SUCCESS', - returnValue: 'ret', - }); - - const result = await pollTransaction('tx1', 2, 10); - expect(result.success).toBe(true); - expect(result.result).toBe('ret'); - }); - - it('returns failure when transaction fails', async () => { - mockGetTransaction.mockResolvedValue({ status: 'FAILED' }); - - const result = await pollTransaction('tx2', 2, 10); - expect(result.success).toBe(false); - expect(result.error).toBe('Transaction failed on-chain'); - }); - - it('returns timeout after max attempts', async () => { - mockGetTransaction.mockResolvedValue({ status: 'NOT_FOUND' }); - - const result = await pollTransaction('tx3', 2, 10); - expect(result.success).toBe(false); - expect(result.error).toBe('Polling timeout'); - expect(mockUpdateTransaction).toHaveBeenCalledWith('tx3', { - status: 'timeout', - error: 'Transaction confirmation timed out — check explorer', - }); - }); -}); +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { Transaction } from '../store/tx-store'; + +const { mockGetTransaction, mockUpdateTransaction } = vi.hoisted(() => ({ + mockGetTransaction: vi.fn(), + mockUpdateTransaction: vi.fn(), +})); + +vi.mock('./soroban-client', () => ({ + getSorobanServer: vi.fn().mockResolvedValue({ getTransaction: mockGetTransaction }), +})); + +vi.mock('../store/tx-store', () => ({ + useTransactionStore: { + getState: vi.fn(), + }, +})); + +vi.mock('@stellar/stellar-sdk', () => ({ + rpc: { + Api: { + GetTransactionStatus: { + SUCCESS: 'SUCCESS', + FAILED: 'FAILED', + NOT_FOUND: 'NOT_FOUND', + }, + }, + }, +})); + +import { checkPendingTransactions, pollTransaction } from './tx-recovery'; +import { useTransactionStore } from '../store/tx-store'; + +type TransactionStoreState = ReturnType; + +function makeTransaction(overrides: Partial): Transaction { + return { + txHash: 'h', + type: 'other', + status: 'pending', + timestamp: Date.now(), + description: 'test', + ...overrides, + }; +} + +function mockState(transactions: Transaction[] = []): TransactionStoreState { + return { + transactions, + _hydrated: true, + addTransaction: vi.fn(), + updateTransaction: mockUpdateTransaction, + getTransaction: vi.fn(), + getPendingTransactions: vi.fn(), + clearOldTransactions: vi.fn(), + setHydrated: vi.fn(), + }; +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(useTransactionStore.getState).mockReturnValue(mockState()); +}); + +describe('checkPendingTransactions', () => { + it('does nothing when no pending transactions', async () => { + vi.mocked(useTransactionStore.getState).mockReturnValue( + mockState([makeTransaction({ txHash: 'h1', status: 'success' })]), + ); + + await checkPendingTransactions(); + expect(mockGetTransaction).not.toHaveBeenCalled(); + }); + + it('marks transaction as success when confirmed', async () => { + vi.mocked(useTransactionStore.getState).mockReturnValue( + mockState([makeTransaction({ txHash: 'h1', status: 'pending' })]), + ); + + mockGetTransaction.mockResolvedValue({ + status: 'SUCCESS', + returnValue: 'result_val', + }); + + await checkPendingTransactions(); + + expect(mockUpdateTransaction).toHaveBeenCalledWith('h1', { + status: 'success', + result: 'result_val', + }); + }); + + it('marks transaction as failed when on-chain fails', async () => { + vi.mocked(useTransactionStore.getState).mockReturnValue( + mockState([makeTransaction({ txHash: 'h2', status: 'pending' })]), + ); + + mockGetTransaction.mockResolvedValue({ status: 'FAILED' }); + + await checkPendingTransactions(); + + expect(mockUpdateTransaction).toHaveBeenCalledWith('h2', { + status: 'failed', + error: 'Transaction failed on-chain', + }); + }); + + it('marks old not-found transaction as failed', async () => { + const oneDayAgo = Date.now() - 25 * 60 * 60 * 1000; + vi.mocked(useTransactionStore.getState).mockReturnValue( + mockState([makeTransaction({ txHash: 'h3', status: 'timeout', timestamp: oneDayAgo })]), + ); + + mockGetTransaction.mockResolvedValue({ status: 'NOT_FOUND' }); + + await checkPendingTransactions(); + + expect(mockUpdateTransaction).toHaveBeenCalledWith('h3', { + status: 'failed', + error: 'Transaction not found (may have expired)', + }); + }); + + it('handles RPC errors gracefully', async () => { + vi.mocked(useTransactionStore.getState).mockReturnValue( + mockState([makeTransaction({ txHash: 'h4', status: 'pending' })]), + ); + + mockGetTransaction.mockRejectedValue(new Error('network error')); + + await checkPendingTransactions(); + + expect(mockUpdateTransaction).not.toHaveBeenCalled(); + }); +}); + +describe('pollTransaction', () => { + it('returns success when transaction confirmed', async () => { + mockGetTransaction.mockResolvedValue({ + status: 'SUCCESS', + returnValue: 'ret', + }); + + const result = await pollTransaction('tx1', 2, 10); + expect(result.success).toBe(true); + expect(result.result).toBe('ret'); + }); + + it('returns failure when transaction fails', async () => { + mockGetTransaction.mockResolvedValue({ status: 'FAILED' }); + + const result = await pollTransaction('tx2', 2, 10); + expect(result.success).toBe(false); + expect(result.error).toBe('Transaction failed on-chain'); + }); + + it('returns timeout after max attempts', async () => { + mockGetTransaction.mockResolvedValue({ status: 'NOT_FOUND' }); + + const result = await pollTransaction('tx3', 2, 10); + expect(result.success).toBe(false); + expect(result.error).toBe('Polling timeout'); + expect(mockUpdateTransaction).toHaveBeenCalledWith('tx3', { + status: 'timeout', + error: 'Transaction confirmation timed out ? check explorer', + }); + }); +}); diff --git a/frontend/src/lib/validation/schemas.ts b/frontend/src/lib/validation/schemas.ts index 171cbc54..11f28734 100644 --- a/frontend/src/lib/validation/schemas.ts +++ b/frontend/src/lib/validation/schemas.ts @@ -1,136 +1,136 @@ -import { z } from "zod"; - -const VALID_DURATIONS = [7, 14, 30, 60, 90] as const; - -export const campaignSchema = z - .object({ - title: z.string().min(1, "Title is required"), - contentId: z.string().min(1, "Content ID is required"), - campaignType: z.coerce - .number() - .int("Campaign type must be a whole number") - .min(1, "Campaign type is required"), - budgetXlm: z - .string() - .min(1, "Budget is required") - .refine((v) => !isNaN(parseFloat(v)) && parseFloat(v) > 0, { - message: "Budget must be a positive number", - }), - costPerViewXlm: z - .string() - .min(1, "Cost per view is required") - .refine((v) => !isNaN(parseFloat(v)) && parseFloat(v) > 0, { - message: "Cost per view must be a positive number", - }), - durationDays: z.coerce - .number() - .refine((v) => (VALID_DURATIONS as readonly number[]).includes(v), { - message: "Select a valid duration", - }), - targetViews: z - .string() - .min(1, "Target views is required") - .refine((v) => !isNaN(parseInt(v)) && parseInt(v) > 0, { - message: "Target views must be a positive number", - }), - dailyViewLimit: z - .string() - .min(1, "Daily view limit is required") - .refine((v) => !isNaN(parseInt(v)) && parseInt(v) > 0, { - message: "Daily view limit must be a positive number", - }), - refundable: z.boolean().default(true), - }) - .refine( - (data) => { - const budget = parseFloat(data.budgetXlm); - const costPerView = parseFloat(data.costPerViewXlm); - const targetViews = parseInt(data.targetViews); - if (isNaN(budget) || isNaN(costPerView) || isNaN(targetViews)) - return true; - const totalCost = costPerView * targetViews; - return budget >= totalCost; - }, - { - message: - "Budget must be sufficient for target views (budget >= cost per view × target views)", - path: ["budgetXlm"], - }, - ) - .refine( - (data) => { - const targetViews = parseInt(data.targetViews); - const dailyLimit = parseInt(data.dailyViewLimit); - if (isNaN(targetViews) || isNaN(dailyLimit)) return true; - return dailyLimit <= targetViews; - }, - { - message: "Daily view limit cannot exceed target views", - path: ["dailyViewLimit"], - }, - ); - -export type CampaignFormData = z.input; - -export function createBidSchema(minBid: number) { - return z.object({ - campaignId: z - .string() - .min(1, "Campaign ID is required") - .refine((v) => !isNaN(parseInt(v)) && parseInt(v) > 0, { - message: "Campaign ID must be a positive integer", - }), - bidAmountXlm: z - .string() - .min(1, "Bid amount is required") - .refine((v) => !isNaN(parseFloat(v)) && parseFloat(v) > 0, { - message: "Bid amount must be a positive number", - }) - .refine((v) => !isNaN(parseFloat(v)) && parseFloat(v) >= minBid, { - message: `Minimum bid is ${minBid.toFixed(4)} XLM`, - }), - }); -} - -export type BidFormData = z.input>; - -export const targetingSchema = z - .object({ - regions: z.array(z.string()), - interests: z.array(z.string()), - excludedSegments: z.array(z.string()), - devices: z.array(z.string()), - languages: z.array(z.string()), - minAge: z.coerce - .number() - .int("Min age must be a whole number") - .min(13, "Min age must be at least 13") - .max(100, "Min age must be at most 100"), - maxAge: z.coerce - .number() - .int("Max age must be a whole number") - .min(13, "Max age must be at least 13") - .max(100, "Max age must be at most 100"), - minReputation: z.coerce - .number() - .int("Reputation must be a whole number") - .min(0, "Reputation must be at least 0") - .max(1000, "Reputation must be at most 1000"), - requireKyc: z.boolean(), - excludeFraud: z.boolean(), - maxCpmXlm: z.string(), - }) - .refine((data) => data.maxAge >= data.minAge, { - message: "Max age must be greater than or equal to min age", - path: ["maxAge"], - }) - .refine( - (data) => { - if (!data.maxCpmXlm) return true; - const val = parseFloat(data.maxCpmXlm); - return !isNaN(val) && val > 0; - }, - { message: "Max CPM must be a positive number", path: ["maxCpmXlm"] }, - ); - -export type TargetingFormData = z.input; +import { z } from "zod"; + +const VALID_DURATIONS = [7, 14, 30, 60, 90] as const; + +export const campaignSchema = z + .object({ + title: z.string().min(1, "Title is required").max(200, "Title must be 200 characters or less"), + contentId: z.string().min(1, "Content ID is required"), + campaignType: z.coerce + .number() + .int("Campaign type must be a whole number") + .min(1, "Campaign type is required"), + budgetXlm: z + .string() + .min(1, "Budget is required") + .refine((v) => !isNaN(parseFloat(v)) && parseFloat(v) > 0, { + message: "Budget must be a positive number", + }), + costPerViewXlm: z + .string() + .min(1, "Cost per view is required") + .refine((v) => !isNaN(parseFloat(v)) && parseFloat(v) > 0, { + message: "Cost per view must be a positive number", + }), + durationDays: z.coerce + .number() + .refine((v) => (VALID_DURATIONS as readonly number[]).includes(v), { + message: "Select a valid duration", + }), + targetViews: z + .string() + .min(1, "Target views is required") + .refine((v) => !isNaN(parseInt(v)) && parseInt(v) > 0, { + message: "Target views must be a positive number", + }), + dailyViewLimit: z + .string() + .min(1, "Daily view limit is required") + .refine((v) => !isNaN(parseInt(v)) && parseInt(v) > 0, { + message: "Daily view limit must be a positive number", + }), + refundable: z.boolean().default(true), + }) + .refine( + (data) => { + const budget = parseFloat(data.budgetXlm); + const costPerView = parseFloat(data.costPerViewXlm); + const targetViews = parseInt(data.targetViews); + if (isNaN(budget) || isNaN(costPerView) || isNaN(targetViews)) + return true; + const totalCost = costPerView * targetViews; + return budget >= totalCost; + }, + { + message: + "Budget must be sufficient for target views (budget >= cost per view ? target views)", + path: ["budgetXlm"], + }, + ) + .refine( + (data) => { + const targetViews = parseInt(data.targetViews); + const dailyLimit = parseInt(data.dailyViewLimit); + if (isNaN(targetViews) || isNaN(dailyLimit)) return true; + return dailyLimit <= targetViews; + }, + { + message: "Daily view limit cannot exceed target views", + path: ["dailyViewLimit"], + }, + ); + +export type CampaignFormData = z.input; + +export function createBidSchema(minBid: number) { + return z.object({ + campaignId: z + .string() + .min(1, "Campaign ID is required") + .refine((v) => !isNaN(parseInt(v)) && parseInt(v) > 0, { + message: "Campaign ID must be a positive integer", + }), + bidAmountXlm: z + .string() + .min(1, "Bid amount is required") + .refine((v) => !isNaN(parseFloat(v)) && parseFloat(v) > 0, { + message: "Bid amount must be a positive number", + }) + .refine((v) => !isNaN(parseFloat(v)) && parseFloat(v) >= minBid, { + message: `Minimum bid is ${minBid.toFixed(4)} XLM`, + }), + }); +} + +export type BidFormData = z.input>; + +export const targetingSchema = z + .object({ + regions: z.array(z.string()), + interests: z.array(z.string()), + excludedSegments: z.array(z.string()), + devices: z.array(z.string()), + languages: z.array(z.string()), + minAge: z.coerce + .number() + .int("Min age must be a whole number") + .min(13, "Min age must be at least 13") + .max(100, "Min age must be at most 100"), + maxAge: z.coerce + .number() + .int("Max age must be a whole number") + .min(13, "Max age must be at least 13") + .max(100, "Max age must be at most 100"), + minReputation: z.coerce + .number() + .int("Reputation must be a whole number") + .min(0, "Reputation must be at least 0") + .max(1000, "Reputation must be at most 1000"), + requireKyc: z.boolean(), + excludeFraud: z.boolean(), + maxCpmXlm: z.string(), + }) + .refine((data) => data.maxAge >= data.minAge, { + message: "Max age must be greater than or equal to min age", + path: ["maxAge"], + }) + .refine( + (data) => { + if (!data.maxCpmXlm) return true; + const val = parseFloat(data.maxCpmXlm); + return !isNaN(val) && val > 0; + }, + { message: "Max CPM must be a positive number", path: ["maxCpmXlm"] }, + ); + +export type TargetingFormData = z.input; diff --git a/frontend/src/lib/wallet.test.ts b/frontend/src/lib/wallet.test.ts index 984a2b34..ddc6fbce 100644 --- a/frontend/src/lib/wallet.test.ts +++ b/frontend/src/lib/wallet.test.ts @@ -34,25 +34,44 @@ import { isConnected as freighterIsConnected, } from '@stellar/freighter-api'; +type ConnectedResult = Awaited>; +type AllowedResult = Awaited>; +type AccessResult = Awaited>; +type AddressResult = Awaited>; +type NetworkDetailsResult = Awaited>; +type SignResult = Awaited>; + +const connectedResult = (isConnected: boolean): ConnectedResult => + ({ isConnected }) as ConnectedResult; +const allowedResult = (isAllowedValue: boolean): AllowedResult => + ({ isAllowed: isAllowedValue }) as AllowedResult; +const accessResult = (error?: string): AccessResult => ({ error }) as AccessResult; +const addressResult = (address?: string, error?: string): AddressResult => + ({ address, error }) as AddressResult; +const networkResult = (networkPassphrase: string): NetworkDetailsResult => + ({ networkPassphrase }) as NetworkDetailsResult; +const signResult = (signedTxXdr?: string, error?: string): SignResult => + ({ signedTxXdr, error }) as SignResult; + beforeEach(() => { vi.clearAllMocks(); }); describe('isWalletConnected', () => { it('returns false when freighter reports not connected', async () => { - vi.mocked(freighterIsConnected).mockResolvedValue({ isConnected: false } as any); + vi.mocked(freighterIsConnected).mockResolvedValue(connectedResult(false)); expect(await isWalletConnected()).toBe(false); }); it('returns false when isAllowed returns false', async () => { - vi.mocked(freighterIsConnected).mockResolvedValue({ isConnected: true } as any); - vi.mocked(isAllowed).mockResolvedValue({ isAllowed: false } as any); + vi.mocked(freighterIsConnected).mockResolvedValue(connectedResult(true)); + vi.mocked(isAllowed).mockResolvedValue(allowedResult(false)); expect(await isWalletConnected()).toBe(false); }); it('returns true when connected and allowed', async () => { - vi.mocked(freighterIsConnected).mockResolvedValue({ isConnected: true } as any); - vi.mocked(isAllowed).mockResolvedValue({ isAllowed: true } as any); + vi.mocked(freighterIsConnected).mockResolvedValue(connectedResult(true)); + vi.mocked(isAllowed).mockResolvedValue(allowedResult(true)); expect(await isWalletConnected()).toBe(true); }); @@ -64,64 +83,62 @@ describe('isWalletConnected', () => { describe('connectWallet', () => { it('throws if freighter not connected', async () => { - vi.mocked(freighterIsConnected).mockResolvedValue({ isConnected: false } as any); + vi.mocked(freighterIsConnected).mockResolvedValue(connectedResult(false)); await expect(connectWallet()).rejects.toThrow('Freighter wallet not found'); }); it('throws on requestAccess error', async () => { - vi.mocked(freighterIsConnected).mockResolvedValue({ isConnected: true } as any); - vi.mocked(requestAccess).mockResolvedValue({ error: 'denied' } as any); + vi.mocked(freighterIsConnected).mockResolvedValue(connectedResult(true)); + vi.mocked(requestAccess).mockResolvedValue(accessResult('denied')); await expect(connectWallet()).rejects.toThrow('denied'); }); it('returns address on success', async () => { - vi.mocked(freighterIsConnected).mockResolvedValue({ isConnected: true } as any); - vi.mocked(requestAccess).mockResolvedValue({} as any); - vi.mocked(getAddress).mockResolvedValue({ address: 'GABC123', error: undefined } as any); + vi.mocked(freighterIsConnected).mockResolvedValue(connectedResult(true)); + vi.mocked(requestAccess).mockResolvedValue(accessResult()); + vi.mocked(getAddress).mockResolvedValue(addressResult('GABC123')); expect(await connectWallet()).toBe('GABC123'); }); it('throws if getAddress fails', async () => { - vi.mocked(freighterIsConnected).mockResolvedValue({ isConnected: true } as any); - vi.mocked(requestAccess).mockResolvedValue({} as any); - vi.mocked(getAddress).mockResolvedValue({ address: undefined, error: 'no addr' } as any); + vi.mocked(freighterIsConnected).mockResolvedValue(connectedResult(true)); + vi.mocked(requestAccess).mockResolvedValue(accessResult()); + vi.mocked(getAddress).mockResolvedValue(addressResult(undefined, 'no addr')); await expect(connectWallet()).rejects.toThrow('no addr'); }); }); describe('getWalletAddress', () => { it('returns null when not connected', async () => { - vi.mocked(freighterIsConnected).mockResolvedValue({ isConnected: false } as any); + vi.mocked(freighterIsConnected).mockResolvedValue(connectedResult(false)); expect(await getWalletAddress()).toBeNull(); }); it('returns address when connected', async () => { - vi.mocked(freighterIsConnected).mockResolvedValue({ isConnected: true } as any); - vi.mocked(isAllowed).mockResolvedValue({ isAllowed: true } as any); - vi.mocked(getAddress).mockResolvedValue({ address: 'GADDR' } as any); + vi.mocked(freighterIsConnected).mockResolvedValue(connectedResult(true)); + vi.mocked(isAllowed).mockResolvedValue(allowedResult(true)); + vi.mocked(getAddress).mockResolvedValue(addressResult('GADDR')); expect(await getWalletAddress()).toBe('GADDR'); }); }); describe('getFreighterNetworkLabel', () => { it('returns testnet for testnet passphrase', async () => { - vi.mocked(getNetworkDetails).mockResolvedValue({ - networkPassphrase: 'Test SDF Network ; September 2015', - } as any); + vi.mocked(getNetworkDetails).mockResolvedValue( + networkResult('Test SDF Network ; September 2015'), + ); expect(await getFreighterNetworkLabel()).toBe('testnet'); }); it('returns public for public passphrase', async () => { - vi.mocked(getNetworkDetails).mockResolvedValue({ - networkPassphrase: 'Public Global Stellar Network ; September 2015', - } as any); + vi.mocked(getNetworkDetails).mockResolvedValue( + networkResult('Public Global Stellar Network ; September 2015'), + ); expect(await getFreighterNetworkLabel()).toBe('public'); }); it('returns unknown for other passphrase', async () => { - vi.mocked(getNetworkDetails).mockResolvedValue({ - networkPassphrase: 'Something else', - } as any); + vi.mocked(getNetworkDetails).mockResolvedValue(networkResult('Something else')); expect(await getFreighterNetworkLabel()).toBe('unknown'); }); @@ -133,16 +150,14 @@ describe('getFreighterNetworkLabel', () => { describe('verifyNetwork', () => { it('returns true when network matches', async () => { - vi.mocked(getNetworkDetails).mockResolvedValue({ - networkPassphrase: 'Test SDF Network ; September 2015', - } as any); + vi.mocked(getNetworkDetails).mockResolvedValue( + networkResult('Test SDF Network ; September 2015'), + ); expect(await verifyNetwork()).toBe(true); }); it('returns false when network mismatches', async () => { - vi.mocked(getNetworkDetails).mockResolvedValue({ - networkPassphrase: 'Wrong Network', - } as any); + vi.mocked(getNetworkDetails).mockResolvedValue(networkResult('Wrong Network')); expect(await verifyNetwork()).toBe(false); }); @@ -154,34 +169,32 @@ describe('verifyNetwork', () => { describe('signTx', () => { it('throws on network mismatch', async () => { - vi.mocked(getNetworkDetails).mockResolvedValue({ - networkPassphrase: 'Wrong Network', - } as any); + vi.mocked(getNetworkDetails).mockResolvedValue(networkResult('Wrong Network')); await expect(signTx('xdr')).rejects.toThrow('Network mismatch'); }); it('signs and returns XDR on success', async () => { - vi.mocked(getNetworkDetails).mockResolvedValue({ - networkPassphrase: 'Test SDF Network ; September 2015', - } as any); - vi.mocked(signTransaction).mockResolvedValue({ signedTxXdr: 'signed_xdr', error: undefined } as any); + vi.mocked(getNetworkDetails).mockResolvedValue( + networkResult('Test SDF Network ; September 2015'), + ); + vi.mocked(signTransaction).mockResolvedValue(signResult('signed_xdr')); expect(await signTx('xdr')).toBe('signed_xdr'); }); it('throws on sign error', async () => { - vi.mocked(getNetworkDetails).mockResolvedValue({ - networkPassphrase: 'Test SDF Network ; September 2015', - } as any); - vi.mocked(signTransaction).mockResolvedValue({ signedTxXdr: undefined, error: 'sign failed' } as any); + vi.mocked(getNetworkDetails).mockResolvedValue( + networkResult('Test SDF Network ; September 2015'), + ); + vi.mocked(signTransaction).mockResolvedValue(signResult(undefined, 'sign failed')); await expect(signTx('xdr')).rejects.toThrow('sign failed'); }); }); describe('getWalletData', () => { it('returns connected data', async () => { - vi.mocked(freighterIsConnected).mockResolvedValue({ isConnected: true } as any); - vi.mocked(isAllowed).mockResolvedValue({ isAllowed: true } as any); - vi.mocked(getAddress).mockResolvedValue({ address: 'GADDR' } as any); + vi.mocked(freighterIsConnected).mockResolvedValue(connectedResult(true)); + vi.mocked(isAllowed).mockResolvedValue(allowedResult(true)); + vi.mocked(getAddress).mockResolvedValue(addressResult('GADDR')); const data = await getWalletData(); expect(data.address).toBe('GADDR'); expect(data.isConnected).toBe(true); @@ -191,7 +204,6 @@ describe('getWalletData', () => { describe('formatAddress', () => { it('truncates address', () => { - // formatAddress slices (0, chars+1) from start and (-chars) from end expect(formatAddress('GBDFPGJKLMNOPQRSTUVWXYZ1234567890ABCDE')).toBe('GBDFP...BCDE'); }); @@ -202,4 +214,4 @@ describe('formatAddress', () => { it('respects custom char count', () => { expect(formatAddress('GBDFPGJKLMNOPQRSTUVWXYZ1234567890ABCDE', 6)).toBe('GBDFPGJ...0ABCDE'); }); -}); +}); \ No newline at end of file