diff --git a/corporate-platform/corporate-platform-backend/src/auth/auth.service.spec.ts b/corporate-platform/corporate-platform-backend/src/auth/auth.service.spec.ts index c1e0642e..d7ce010b 100644 --- a/corporate-platform/corporate-platform-backend/src/auth/auth.service.spec.ts +++ b/corporate-platform/corporate-platform-backend/src/auth/auth.service.spec.ts @@ -58,7 +58,10 @@ describe('AuthService Refresh Token Reuse', () => { }); it('should detect reuse and invalidate sessions', async () => { - (jwt.verify as jest.Mock).mockReturnValue({ sessionId: 'session-1', sub: 'user-1' }); + (jwt.verify as jest.Mock).mockReturnValue({ + sessionId: 'session-1', + sub: 'user-1', + }); (prisma.session.findUnique as jest.Mock).mockResolvedValue({ id: 'session-1', @@ -97,7 +100,10 @@ describe('AuthService Refresh Token Reuse', () => { }); it('should lock session after 5 failed attempts', async () => { - (jwt.verify as jest.Mock).mockReturnValue({ sessionId: 'session-1', sub: 'user-1' }); + (jwt.verify as jest.Mock).mockReturnValue({ + sessionId: 'session-1', + sub: 'user-1', + }); (prisma.session.findUnique as jest.Mock).mockResolvedValue({ id: 'session-1', @@ -127,7 +133,10 @@ describe('AuthService Refresh Token Reuse', () => { }); it('should reject locked sessions', async () => { - (jwt.verify as jest.Mock).mockReturnValue({ sessionId: 'session-1', sub: 'user-1' }); + (jwt.verify as jest.Mock).mockReturnValue({ + sessionId: 'session-1', + sub: 'user-1', + }); (prisma.session.findUnique as jest.Mock).mockResolvedValue({ id: 'session-1', @@ -140,4 +149,4 @@ describe('AuthService Refresh Token Reuse', () => { service.refresh({ refreshToken: 'any-token' }), ).rejects.toThrow(SessionLockedError); }); -}); \ No newline at end of file +}); diff --git a/corporate-platform/corporate-platform-backend/src/config/validation/config.schema.ts b/corporate-platform/corporate-platform-backend/src/config/validation/config.schema.ts index 2566e960..a3e646c0 100644 --- a/corporate-platform/corporate-platform-backend/src/config/validation/config.schema.ts +++ b/corporate-platform/corporate-platform-backend/src/config/validation/config.schema.ts @@ -42,8 +42,12 @@ export const configSchema = Joi.object({ HORIZON_URL: Joi.string().uri().allow(''), SOROBAN_RPC_URL: Joi.string().uri().allow(''), // Signing (#542): explicit mode — never treat missing secret as silent simulate - STELLAR_SIGNING_MODE: Joi.string().valid('simulate', 'live').default('simulate'), - STELLAR_SIGNING_PROVIDER: Joi.string().valid('env', 'kms', 'vault').default('env'), + STELLAR_SIGNING_MODE: Joi.string() + .valid('simulate', 'live') + .default('simulate'), + STELLAR_SIGNING_PROVIDER: Joi.string() + .valid('env', 'kms', 'vault') + .default('env'), STELLAR_SECRET_KEY: Joi.string().allow('', null), STELLAR_TRANSFER_SECRET_KEY: Joi.string().allow('', null), STELLAR_KMS_KEY_ID: Joi.string().allow('', null), diff --git a/corporate-platform/corporate-platform-backend/src/ipfs/ipfs.controller.ts b/corporate-platform/corporate-platform-backend/src/ipfs/ipfs.controller.ts index 0e3d5279..b52347d4 100644 --- a/corporate-platform/corporate-platform-backend/src/ipfs/ipfs.controller.ts +++ b/corporate-platform/corporate-platform-backend/src/ipfs/ipfs.controller.ts @@ -149,8 +149,15 @@ export class IpfsController { } @Get('documents') - async listDocuments(@CurrentUser() user: JwtPayload) { - return this.upload.listDocuments(user.companyId); + async listDocuments( + @CurrentUser() user: JwtPayload, + @Query('page') page?: string, + @Query('limit') limit?: string, + ) { + return this.upload.listDocuments(user.companyId, { + page: page ? parseInt(page, 10) : undefined, + limit: limit ? parseInt(limit, 10) : undefined, + }); } @Get('documents/:referenceId') diff --git a/corporate-platform/corporate-platform-backend/src/ipfs/services/upload.service.ts b/corporate-platform/corporate-platform-backend/src/ipfs/services/upload.service.ts index 4faf88d4..e90b5894 100644 --- a/corporate-platform/corporate-platform-backend/src/ipfs/services/upload.service.ts +++ b/corporate-platform/corporate-platform-backend/src/ipfs/services/upload.service.ts @@ -207,9 +207,20 @@ export class UploadService { ); } - async listDocuments(companyId?: string) { + async listDocuments( + companyId?: string, + pagination?: { page?: number; limit?: number }, + ) { + const take = pagination?.limit; + const skip = + pagination?.page && pagination?.limit + ? (pagination.page - 1) * pagination.limit + : undefined; + return this.prisma.ipfsDocument.findMany({ where: companyId ? { companyId } : {}, + ...(skip !== undefined ? { skip } : {}), + ...(take !== undefined ? { take } : {}), }); } diff --git a/corporate-platform/corporate-platform-backend/src/sbti/services/progress-tracking.service.ts b/corporate-platform/corporate-platform-backend/src/sbti/services/progress-tracking.service.ts index ca7e4747..0821090e 100644 --- a/corporate-platform/corporate-platform-backend/src/sbti/services/progress-tracking.service.ts +++ b/corporate-platform/corporate-platform-backend/src/sbti/services/progress-tracking.service.ts @@ -65,10 +65,7 @@ export class ProgressTrackingService { const base = Number(target.baseYearEmissions); const reduction = Number(target.reductionPercentage) / 100; const span = Math.max(1, target.targetYear - target.baseYear); - const progress = Math.min( - 1, - Math.max(0, (year - target.baseYear) / span), - ); + const progress = Math.min(1, Math.max(0, (year - target.baseYear) / span)); const targetAtEnd = base * (1 - reduction); return base + (targetAtEnd - base) * progress; } @@ -87,10 +84,7 @@ export class ProgressTrackingService { const requiredCut = base * reduction; if (requiredCut <= 0) return 0; const actualCut = base - latestActual; - return Math.max( - 0, - Math.min(100, (actualCut / requiredCut) * 100), - ); + return Math.max(0, Math.min(100, (actualCut / requiredCut) * 100)); } classifyTrackStatus( @@ -124,8 +118,7 @@ export class ProgressTrackingService { } return years.map((year) => ({ year, - actualEmissions: - byYear.get(year) ?? Number(target.baseYearEmissions), + actualEmissions: byYear.get(year) ?? Number(target.baseYearEmissions), targetEmissions: this.expectedEmissionsAtYear(target, year), })); } @@ -144,9 +137,7 @@ export class ProgressTrackingService { const targetEntries: TargetDashboardEntry[] = targets.map((t) => { const rows = progressRows.filter((p) => p.targetId === t.id); const series = this.buildSeries(t, rows); - const latest = series.length - ? series[series.length - 1] - : null; + const latest = series.length ? series[series.length - 1] : null; const latestEmissions = latest ? latest.actualEmissions : null; const latestYear = latest ? latest.year : t.baseYear; return { diff --git a/corporate-platform/corporate-platform-backend/src/stellar/signing/env-signing.provider.spec.ts b/corporate-platform/corporate-platform-backend/src/stellar/signing/env-signing.provider.spec.ts index 092da1af..057e2657 100644 --- a/corporate-platform/corporate-platform-backend/src/stellar/signing/env-signing.provider.spec.ts +++ b/corporate-platform/corporate-platform-backend/src/stellar/signing/env-signing.provider.spec.ts @@ -33,9 +33,9 @@ describe('EnvSigningProvider (#542)', () => { it('signTransaction rejects in simulate mode', async () => { process.env.STELLAR_SIGNING_MODE = 'simulate'; const p = new EnvSigningProvider('transfer'); - await expect(p.signTransaction('AAAA', 'Test SDF Network ; September 2015')).rejects.toThrow( - /simulate mode/i, - ); + await expect( + p.signTransaction('AAAA', 'Test SDF Network ; September 2015'), + ).rejects.toThrow(/simulate mode/i); }); it('KmsSigningProvider fails closed when selected without public key', () => { diff --git a/corporate-platform/corporate-platform-backend/src/stellar/signing/kms-signing.provider.ts b/corporate-platform/corporate-platform-backend/src/stellar/signing/kms-signing.provider.ts index d6a451be..30bf44aa 100644 --- a/corporate-platform/corporate-platform-backend/src/stellar/signing/kms-signing.provider.ts +++ b/corporate-platform/corporate-platform-backend/src/stellar/signing/kms-signing.provider.ts @@ -42,7 +42,9 @@ export class KmsSigningProvider implements SigningProvider, OnModuleInit { onModuleInit(): void { if (!this.enabled) { - this.logger.debug(`KmsSigningProvider not selected (category=${this.category})`); + this.logger.debug( + `KmsSigningProvider not selected (category=${this.category})`, + ); return; } if (!this.publicKey) { diff --git a/corporate-platform/corporate-platform-backend/src/stellar/signing/signing-provider.interface.ts b/corporate-platform/corporate-platform-backend/src/stellar/signing/signing-provider.interface.ts index 884b88f4..34f11bb0 100644 --- a/corporate-platform/corporate-platform-backend/src/stellar/signing/signing-provider.interface.ts +++ b/corporate-platform/corporate-platform-backend/src/stellar/signing/signing-provider.interface.ts @@ -25,7 +25,10 @@ export interface SigningProvider { * Sign a prepared transaction XDR string. * Implementations must not retain secret material longer than needed. */ - signTransaction(txXdr: string, networkPassphrase: string): Promise; + signTransaction( + txXdr: string, + networkPassphrase: string, + ): Promise; /** True when this provider produces real on-chain signatures */ isLive(): boolean; diff --git a/corporate-platform/corporate-platform-backend/src/stellar/soroban/soroban.module.ts b/corporate-platform/corporate-platform-backend/src/stellar/soroban/soroban.module.ts index 9eecbd8d..ba1bd3f0 100644 --- a/corporate-platform/corporate-platform-backend/src/stellar/soroban/soroban.module.ts +++ b/corporate-platform/corporate-platform-backend/src/stellar/soroban/soroban.module.ts @@ -18,7 +18,12 @@ import { ConfigModule } from '../../config/config.module'; import { SigningModule } from '../signing/signing.module'; @Module({ - imports: [OwnershipHistoryModule, IdempotencyModule, ConfigModule, SigningModule], + imports: [ + OwnershipHistoryModule, + IdempotencyModule, + ConfigModule, + SigningModule, + ], providers: [ SorobanService, CarbonAssetService, diff --git a/corporate-platform/corporate-platform-backend/src/webhooks/dto/soroban-event.dto.ts b/corporate-platform/corporate-platform-backend/src/webhooks/dto/soroban-event.dto.ts index bcf6799a..452df848 100644 --- a/corporate-platform/corporate-platform-backend/src/webhooks/dto/soroban-event.dto.ts +++ b/corporate-platform/corporate-platform-backend/src/webhooks/dto/soroban-event.dto.ts @@ -1,4 +1,11 @@ -import { IsArray, IsBoolean, IsNotEmpty, IsNumber, IsString, ValidateNested } from 'class-validator'; +import { + IsArray, + IsBoolean, + IsNotEmpty, + IsNumber, + IsString, + ValidateNested, +} from 'class-validator'; import { Type } from 'class-transformer'; class SorobanEventValueDto { diff --git a/corporate-platform/corporate-platform-backend/src/webhooks/guards/webhook-signature.guard.ts b/corporate-platform/corporate-platform-backend/src/webhooks/guards/webhook-signature.guard.ts index 31f85e2b..fcdc5182 100644 --- a/corporate-platform/corporate-platform-backend/src/webhooks/guards/webhook-signature.guard.ts +++ b/corporate-platform/corporate-platform-backend/src/webhooks/guards/webhook-signature.guard.ts @@ -1,4 +1,9 @@ -import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common'; +import { + CanActivate, + ExecutionContext, + Injectable, + UnauthorizedException, +} from '@nestjs/common'; import { createHmac, timingSafeEqual } from 'crypto'; import { Request } from 'express'; import { SecurityEvents } from '../../security/constants/security-events.constants'; @@ -8,7 +13,10 @@ type RawRequest = Request & { rawBody?: Buffer }; @Injectable() export class WebhookSignatureGuard implements CanActivate { - private readonly requests = new Map(); + private readonly requests = new Map< + string, + { started: number; count: number } + >(); constructor(private readonly security: SecurityService) {} @@ -17,26 +25,44 @@ export class WebhookSignatureGuard implements CanActivate { const ip = request.ip || 'unknown'; const now = Date.now(); const bucket = this.requests.get(ip); - if (!bucket || now - bucket.started >= 60_000) this.requests.set(ip, { started: now, count: 1 }); + if (!bucket || now - bucket.started >= 60_000) + this.requests.set(ip, { started: now, count: 1 }); else if (++bucket.count > 100) await this.reject(request, 'rate-limit'); const secret = process.env.WEBHOOK_SIGNING_SECRET; const timestamp = request.header('X-Webhook-Timestamp'); - const supplied = request.header('X-Webhook-Signature')?.replace(/^sha256=/i, '') ?? ''; + const supplied = + request.header('X-Webhook-Signature')?.replace(/^sha256=/i, '') ?? ''; const rawBody = request.rawBody; const timestampMs = timestamp ? Number(timestamp) * 1000 : NaN; - const fresh = Number.isFinite(timestampMs) && Math.abs(now - timestampMs) <= 5 * 60_000; - const payload = rawBody && timestamp ? `${timestamp}.${rawBody.toString('utf8')}` : ''; - const expected = secret && payload ? createHmac('sha256', secret).update(payload).digest('hex') : ''; - const valid = Boolean(expected && supplied.length === expected.length && timingSafeEqual(Buffer.from(supplied), Buffer.from(expected))); - if (!secret || !rawBody || !valid || !fresh) await this.reject(request, !fresh ? 'stale-timestamp' : 'invalid-signature'); + const fresh = + Number.isFinite(timestampMs) && Math.abs(now - timestampMs) <= 5 * 60_000; + const payload = + rawBody && timestamp ? `${timestamp}.${rawBody.toString('utf8')}` : ''; + const expected = + secret && payload + ? createHmac('sha256', secret).update(payload).digest('hex') + : ''; + const valid = Boolean( + expected && + supplied.length === expected.length && + timingSafeEqual(Buffer.from(supplied), Buffer.from(expected)), + ); + if (!secret || !rawBody || !valid || !fresh) + await this.reject( + request, + !fresh ? 'stale-timestamp' : 'invalid-signature', + ); return true; } private async reject(request: RawRequest, reason: string): Promise { await this.security.logEvent({ eventType: SecurityEvents.SuspiciousPatternDetected, - companyId: typeof request.body?.companyId === 'string' ? request.body.companyId : null, + companyId: + typeof request.body?.companyId === 'string' + ? request.body.companyId + : null, ipAddress: request.ip, method: request.method, resource: request.originalUrl, diff --git a/corporate-platform/corporate-platform-backend/src/webhooks/services/stellar-webhook.service.ts b/corporate-platform/corporate-platform-backend/src/webhooks/services/stellar-webhook.service.ts index c636a741..e716cac3 100644 --- a/corporate-platform/corporate-platform-backend/src/webhooks/services/stellar-webhook.service.ts +++ b/corporate-platform/corporate-platform-backend/src/webhooks/services/stellar-webhook.service.ts @@ -1,4 +1,9 @@ -import { ConflictException, Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { + ConflictException, + Injectable, + Logger, + NotFoundException, +} from '@nestjs/common'; import { PrismaService } from '../../shared/database/prisma.service'; import { StellarWebhookDto, @@ -22,7 +27,9 @@ export class StellarWebhookService { select: { companyId: true }, }); if (existing && existing.companyId !== dto.companyId) { - throw new ConflictException('Transaction confirmation belongs to another company'); + throw new ConflictException( + 'Transaction confirmation belongs to another company', + ); } return this.prisma.transactionConfirmation.upsert({ diff --git a/corporate-platform/corporate-platform-backend/test/auction.e2e-spec.ts b/corporate-platform/corporate-platform-backend/test/auction.e2e-spec.ts index 45694ed3..62db04ca 100644 --- a/corporate-platform/corporate-platform-backend/test/auction.e2e-spec.ts +++ b/corporate-platform/corporate-platform-backend/test/auction.e2e-spec.ts @@ -19,7 +19,7 @@ describe('Auction API Integration Tests', () => { let app: INestApplication; const mockCompanyId = 'test-company-id-1'; const mockAuthToken = 'valid-jwt-token'; - + const mockPrismaService = new Proxy({} as any, { get: (target, prop) => { if (typeof prop === 'string' && !target[prop]) { @@ -78,15 +78,11 @@ describe('Auction API Integration Tests', () => { describe('Unauthenticated Requests', () => { it('should return 401 for GET /api/v1/auctions', async () => { - await request(app.getHttpServer()) - .get('/api/v1/auctions') - .expect(401); + await request(app.getHttpServer()).get('/api/v1/auctions').expect(401); }); it('should return 401 for GET /api/v1/auctions/:id', async () => { - await request(app.getHttpServer()) - .get('/api/v1/auctions/1') - .expect(401); + await request(app.getHttpServer()).get('/api/v1/auctions/1').expect(401); }); it('should return 401 for POST /api/v1/auctions', async () => { @@ -125,7 +121,7 @@ describe('Auction API Integration Tests', () => { describe('Authenticated Requests', () => { it('should allow GET /api/v1/auctions when authenticated', async () => { mockPrismaService.findMany = jest.fn().mockResolvedValue([]); - + await request(app.getHttpServer()) .get('/api/v1/auctions') .set('Authorization', `Bearer ${mockAuthToken}`) diff --git a/corporate-platform/corporate-platform-web/package-lock.json b/corporate-platform/corporate-platform-web/package-lock.json index 8835321c..f95924b5 100644 --- a/corporate-platform/corporate-platform-web/package-lock.json +++ b/corporate-platform/corporate-platform-web/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "dependencies": { "@hookform/resolvers": "^5.2.2", + "@tanstack/react-virtual": "^3.14.10", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "date-fns": "^4.1.0", @@ -2086,6 +2087,33 @@ "tailwindcss": "4.3.0" } }, + "node_modules/@tanstack/react-virtual": { + "version": "3.14.10", + "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.14.10.tgz", + "integrity": "sha512-SRyoUbdFMRHuYXMijV5H4ZarQWpXkj3iANq8OFre+pybeVap8ZJjZ3Nz9bVjx4d8PfobVUQUdKyyyHYk3E+djw==", + "license": "MIT", + "dependencies": { + "@tanstack/virtual-core": "3.17.8" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tanstack/virtual-core": { + "version": "3.17.8", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.17.8.tgz", + "integrity": "sha512-BfEvehNpOT75r5Ksc5xW6NZuXujTfb7nlSEyVu4XHG3gdxNg1KqXruWbDewXOUaUYIo4oRbSfkjIajz4MAT8tA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@testing-library/dom": { "version": "10.4.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", diff --git a/corporate-platform/corporate-platform-web/package.json b/corporate-platform/corporate-platform-web/package.json index d97d5f1c..e65562be 100644 --- a/corporate-platform/corporate-platform-web/package.json +++ b/corporate-platform/corporate-platform-web/package.json @@ -12,6 +12,7 @@ }, "dependencies": { "@hookform/resolvers": "^5.2.2", + "@tanstack/react-virtual": "^3.14.10", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "date-fns": "^4.1.0", @@ -26,10 +27,10 @@ "zustand": "^5.0.10" }, "devDependencies": { + "@tailwindcss/postcss": "^4", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.0", "@testing-library/user-event": "^14.6.1", - "@tailwindcss/postcss": "^4", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", diff --git a/corporate-platform/corporate-platform-web/src/app/portfolio/page.test.tsx b/corporate-platform/corporate-platform-web/src/app/portfolio/page.test.tsx new file mode 100644 index 00000000..c61b6eb5 --- /dev/null +++ b/corporate-platform/corporate-platform-web/src/app/portfolio/page.test.tsx @@ -0,0 +1,109 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { describe, it, expect, vi } from 'vitest'; +import PortfolioPage from '@/app/portfolio/page'; + +const mockFetchPortfolioHoldings = vi.fn(); + +const mockHoldings = Array.from({ length: 25 }, (_, i) => ({ + id: `holding-${i + 1}`, + quantity: 100 * (i + 1), + purchasePrice: 15.5, + currentValue: 16.0, + purchaseDate: '2026-01-15T00:00:00.000Z', + credit: { + id: `credit-${i + 1}`, + projectName: `Carbon Project ${i + 1}`, + }, +})); + +vi.mock('@/contexts/CorporateContext', () => ({ + useCorporate: () => ({ + portfolioSummary: { + totalRetired: 1000, + availableBalance: 5000, + quarterlyGrowth: 12.5, + netZeroProgress: 68, + scope3Coverage: 40, + sdgAlignment: 9, + costEfficiency: 92, + lastUpdatedAt: '2026-01-01', + }, + portfolioAnalytics: { + summary: { totalRetired: 1000 }, + performance: { + portfolioValue: 1650000, + avgPricePerTon: 15.5, + creditsHeld: 5000, + projectDiversity: 8, + performanceTrends: [], + monthlyRetirements: [], + }, + composition: { + methodologyDistribution: [{ name: 'Reforestation', value: 40, percentage: 40 }], + geographicAllocation: [], + sdgImpact: [], + vintageYearDistribution: [], + projectTypeClassification: [], + }, + timeline: { + portfolioGrowth: { monthly: [{ date: 'Jan', value: 100 }] }, + retirementTrends: { monthly: [{ date: 'Jan', value: 50 }] }, + valueOverTime: {}, + }, + risk: { + diversificationScore: 85, + riskRating: 'Low', + concentrationAnalysis: {}, + volatility: 5, + }, + generatedAt: '2026-01-01', + }, + portfolioHoldings: mockHoldings.slice(0, 10), + portfolioHoldingsPagination: { + total: 25, + page: 1, + pageSize: 10, + pages: 3, + }, + fetchPortfolioHoldings: mockFetchPortfolioHoldings, + portfolioLoading: false, + portfolioError: null, + }), +})); + +// Mock recharts ResponsiveContainer +vi.mock('recharts', async () => { + const actual = await vi.importActual('recharts'); + return { + ...actual, + ResponsiveContainer: ({ children }: any) =>
{children}
, + }; +}); + +describe('PortfolioPage Pagination & Virtualization', () => { + it('renders transactions table and pagination controls', () => { + render(); + + expect(screen.getByText('Carbon Project 1')).toBeInTheDocument(); + expect(screen.getByText('Carbon Project 10')).toBeInTheDocument(); + expect(screen.getByTestId('pagination-info')).toHaveTextContent('Showing 1 to 10 of 25 transactions'); + }); + + it('triggers fetchPortfolioHoldings on page navigation', () => { + render(); + + const nextButton = screen.getByRole('button', { name: /next page/i }); + fireEvent.click(nextButton); + + expect(mockFetchPortfolioHoldings).toHaveBeenCalledWith({ page: 2, pageSize: 10 }); + }); + + it('triggers fetchPortfolioHoldings on page size change', () => { + render(); + + const select = screen.getByLabelText(/select rows per page for transactions/i); + fireEvent.change(select, { target: { value: '20' } }); + + expect(mockFetchPortfolioHoldings).toHaveBeenCalledWith({ page: 1, pageSize: 20 }); + }); +}); diff --git a/corporate-platform/corporate-platform-web/src/app/portfolio/page.tsx b/corporate-platform/corporate-platform-web/src/app/portfolio/page.tsx index 1fd2c5bd..6d9e2b5c 100644 --- a/corporate-platform/corporate-platform-web/src/app/portfolio/page.tsx +++ b/corporate-platform/corporate-platform-web/src/app/portfolio/page.tsx @@ -1,23 +1,25 @@ 'use client' -import { useState } from 'react' +import { useRef, useState } from 'react' import { TrendingUp, TrendingDown, DollarSign, Package, Globe, - BarChart3, - Download, - Calendar, - MapPin, - Shield, - PieChart, - LineChart as LineChartIcon, - Award, - Target + BarChart3, + Download, + Calendar, + MapPin, + Shield, + PieChart, + LineChart as LineChartIcon, + Award, + Target } from 'lucide-react' +import { useVirtualizer } from '@tanstack/react-virtual' import { useCorporate } from '@/contexts/CorporateContext' +import { Pagination } from '@/components/common/Pagination' import { LineChart, Line, AreaChart, Area, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, PieChart as RechartsPieChart, Pie, Cell } from 'recharts' export default function PortfolioPage() { @@ -25,10 +27,13 @@ export default function PortfolioPage() { portfolioSummary, portfolioAnalytics, portfolioHoldings, + portfolioHoldingsPagination, + fetchPortfolioHoldings, portfolioLoading, portfolioError, } = useCorporate(); const [timeRange, setTimeRange] = useState<'1m' | '3m' | '6m' | '1y' | 'all'>('6m'); + const transactionsContainerRef = useRef(null); // Prepare data from API const growthData = portfolioAnalytics?.timeline?.portfolioGrowth?.monthly?.map((item: any) => ({ @@ -53,6 +58,37 @@ export default function PortfolioPage() { status: 'Completed', // TODO: map real status if available })); + const rowVirtualizer = useVirtualizer({ + count: recentTransactions.length, + getScrollElement: () => transactionsContainerRef.current, + estimateSize: () => 56, + overscan: 5, + initialRect: { width: 1000, height: 400 }, + }); + + const virtualRows = rowVirtualizer.getVirtualItems(); + const totalSize = rowVirtualizer.getTotalSize(); + const paddingTop = virtualRows.length > 0 ? virtualRows[0]?.start || 0 : 0; + const paddingBottom = + virtualRows.length > 0 + ? totalSize - (virtualRows[virtualRows.length - 1]?.end || 0) + : 0; + + const rowsToRender = + virtualRows.length > 0 + ? virtualRows.map((vr) => ({ + index: vr.index, + key: vr.key, + tx: recentTransactions[vr.index], + measureRef: rowVirtualizer.measureElement, + })) + : recentTransactions.map((tx, idx) => ({ + index: idx, + key: tx.id || idx, + tx, + measureRef: undefined, + })); + const performanceMetrics = portfolioAnalytics ? [ { label: 'Portfolio Value', value: `$${portfolioAnalytics.performance.portfolioValue?.toLocaleString()}`, change: '+12.5%', trend: 'up', icon: DollarSign }, { label: 'Avg. Price/ton', value: `$${portfolioAnalytics.performance.avgPricePerTon}`, change: '-2.1%', trend: 'down', icon: TrendingDown }, @@ -180,8 +216,8 @@ export default function PortfolioPage() { {/* Recent Transactions */} -
-
+
+

Recent Transactions

Purchase and retirement activity

@@ -191,58 +227,97 @@ export default function PortfolioPage() { Export CSV
-
- - - - - - - - - - - - - {recentTransactions.map((tx) => ( - - - - - - - - - ))} - -
DateTypeProjectAmountPriceStatus
-
- {new Date(tx.date).toLocaleDateString()} -
-
-
- {tx.type} -
-
-
{tx.project}
-
-
{tx.amount.toLocaleString()} tCO₂
-
-
${tx.price}/ton
-
-
- {tx.status} -
-
-
+ {recentTransactions.length === 0 ? ( +
No transactions found.
+ ) : ( +
+
+ + + + + + + + + + + + + {paddingTop > 0 && ( + + + )} + {rowsToRender.map(({ index, key, tx, measureRef }) => { + if (!tx) return null + return ( + + + + + + + + + ) + })} + {paddingBottom > 0 && ( + + + )} + +
DateTypeProjectAmountPriceStatus
+
+
+ {tx.date ? new Date(tx.date).toLocaleDateString() : 'N/A'} +
+
+
+ {tx.type} +
+
+
{tx.project}
+
+
{tx.amount.toLocaleString()} tCO₂
+
+
${tx.price}/ton
+
+
+ {tx.status} +
+
+
+
+ + { + void fetchPortfolioHoldings({ page: newPage, pageSize: portfolioHoldingsPagination.pageSize }) + }} + onPageSizeChange={(newSize) => { + void fetchPortfolioHoldings({ page: 1, pageSize: newSize }) + }} + showPageSizeSelector + /> +
+ )}
diff --git a/corporate-platform/corporate-platform-web/src/components/audit/AuditTrailViewer.test.tsx b/corporate-platform/corporate-platform-web/src/components/audit/AuditTrailViewer.test.tsx new file mode 100644 index 00000000..3117253b --- /dev/null +++ b/corporate-platform/corporate-platform-web/src/components/audit/AuditTrailViewer.test.tsx @@ -0,0 +1,82 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { beforeEach, describe, it, expect, vi } from 'vitest'; +import AuditTrailViewer from '@/components/audit/AuditTrailViewer'; +import { queryAuditEvents } from '@/lib/api/audit.api'; + +vi.mock('@/lib/api/audit.api', () => ({ + queryAuditEvents: vi.fn(), + exportAuditEvents: vi.fn(), +})); + +vi.mock('@/hooks/useAccessibility', () => ({ + useAccessibility: () => ({ labels: { closeCart: 'Close' } }), +})); + +vi.mock('@/hooks/useAnnouncement', () => ({ + useAnnouncement: () => ({ announce: vi.fn() }), +})); + +vi.mock('@/lib/telemetry/errorReporter', () => ({ + reportError: vi.fn(), +})); + +const mockQueryAuditEvents = vi.mocked(queryAuditEvents); + +const mockEvents = Array.from({ length: 20 }, (_, i) => ({ + id: `event-${i + 1}`, + companyId: 'company-1', + userId: 'user-1', + eventType: 'RETIREMENT', + action: 'CREATE', + entityType: 'RETIREMENT', + entityId: `retirement-id-${i + 1}`, + hash: `hash-${i + 1}-1234567890123456`, + previousHash: `prev-hash-${i + 1}`, + timestamp: '2026-01-01T12:00:00.000Z', + createdAt: '2026-01-01T12:00:00.000Z', +})); + +describe('AuditTrailViewer Pagination & Virtualization', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockQueryAuditEvents.mockResolvedValue({ + events: mockEvents, + total: 50, + page: 1, + limit: 20, + }); + }); + + it('renders events table and shared pagination controls', async () => { + render(); + + expect(await screen.findByText('Audit Trail')).toBeInTheDocument(); + expect(screen.getByTestId('pagination-info')).toHaveTextContent('Showing 1 to 20 of 50 events'); + }); + + it('navigates to next page on pagination click', async () => { + render(); + + await screen.findByText('Audit Trail'); + + const nextBtn = screen.getByRole('button', { name: /next page/i }); + fireEvent.click(nextBtn); + + expect(mockQueryAuditEvents).toHaveBeenCalledWith( + expect.objectContaining({ page: 2, limit: 20 }) + ); + }); + + it('changes page size using selector', async () => { + render(); + + await screen.findByText('Audit Trail'); + + const pageSizeSelect = screen.getByLabelText(/select rows per page for events/i); + fireEvent.change(pageSizeSelect, { target: { value: '50' } }); + + expect(mockQueryAuditEvents).toHaveBeenCalledWith( + expect.objectContaining({ page: 1, limit: 50 }) + ); + }); +}); diff --git a/corporate-platform/corporate-platform-web/src/components/audit/AuditTrailViewer.tsx b/corporate-platform/corporate-platform-web/src/components/audit/AuditTrailViewer.tsx index 55aa76ab..bc67e527 100644 --- a/corporate-platform/corporate-platform-web/src/components/audit/AuditTrailViewer.tsx +++ b/corporate-platform/corporate-platform-web/src/components/audit/AuditTrailViewer.tsx @@ -1,6 +1,7 @@ 'use client'; import React, { useState, useEffect, useRef } from 'react'; +import { useVirtualizer } from '@tanstack/react-virtual'; import { queryAuditEvents, exportAuditEvents } from '@/lib/api/audit.api'; import type { AuditEvent, AuditQueryParams, AuditEventType, AuditAction } from '@/types/audit.types'; import { formatDate, formatEventType, formatAction } from '@/lib/utils/audit-formatters'; @@ -9,6 +10,7 @@ import { useAccessibility } from '@/hooks/useAccessibility'; import { useAnnouncement } from '@/hooks/useAnnouncement'; import { IconButton } from '@/components/common/IconButton'; import { AccessibleIcon } from '@/components/common/AccessibleIcon'; +import { Pagination } from '@/components/common/Pagination'; interface AuditTrailViewerProps { entityType?: string; @@ -52,6 +54,38 @@ export default function AuditTrailViewer({ const closeButtonRef = useRef(null); const titleRef = useRef(null); const previousFocusRef = useRef(null); + const tableContainerRef = useRef(null); + + const rowVirtualizer = useVirtualizer({ + count: events.length, + getScrollElement: () => tableContainerRef.current, + estimateSize: () => 48, + overscan: 5, + initialRect: { width: 1000, height: 400 }, + }); + + const virtualRows = rowVirtualizer.getVirtualItems(); + const totalSize = rowVirtualizer.getTotalSize(); + const paddingTop = virtualRows.length > 0 ? virtualRows[0]?.start || 0 : 0; + const paddingBottom = + virtualRows.length > 0 + ? totalSize - (virtualRows[virtualRows.length - 1]?.end || 0) + : 0; + + const rowsToRender = + virtualRows.length > 0 + ? virtualRows.map((vr) => ({ + index: vr.index, + key: vr.key, + event: events[vr.index], + measureRef: rowVirtualizer.measureElement, + })) + : events.map((event, idx) => ({ + index: idx, + key: event.id || idx, + event, + measureRef: undefined, + })); useEffect(() => { loadEvents(); @@ -379,13 +413,13 @@ export default function AuditTrailViewer({ {/* Events Table */}
-
+
- + - {events.map((event) => ( - - - - - - {!compact && ( - + + )} + {rowsToRender.map(({ index, key, event, measureRef }) => { + if (!event) return null; + return ( + + + + - )} + + {!compact && ( + + )} + + ); + })} + {paddingBottom > 0 && ( + + - ))} + )}
Timestamp @@ -407,39 +441,54 @@ export default function AuditTrailViewer({
- {formatDate(event.timestamp)} - - - {formatEventType(event.eventType)} - - - - {formatAction(event.action)} - - - {event.entityType}: {event.entityId.substring(0, 8)}... - - {event.hash.substring(0, 16)}... + {paddingTop > 0 && ( +
+
+ {formatDate(event.timestamp)} + + + {formatEventType(event.eventType)} + + + + {formatAction(event.action)} + + {event.entityType}: {event.entityId.substring(0, 8)}... + + {event.hash.substring(0, 16)}... +
@@ -456,32 +505,19 @@ export default function AuditTrailViewer({ {/* Pagination */} {!compact && totalPages > 1 && ( -
-
- Showing {(page - 1) * (filters.limit || 20) + 1} to {Math.min(page * (filters.limit || 20), total)} of {total} events -
-
- - - Page {page} of {totalPages} - - -
-
+ setPage(newPage)} + onPageSizeChange={(newSize) => { + setFilters((prev) => ({ ...prev, limit: newSize })); + setPage(1); + }} + showPageSizeSelector + /> )}
diff --git a/corporate-platform/corporate-platform-web/src/components/common/Pagination.test.tsx b/corporate-platform/corporate-platform-web/src/components/common/Pagination.test.tsx new file mode 100644 index 00000000..5bdfb771 --- /dev/null +++ b/corporate-platform/corporate-platform-web/src/components/common/Pagination.test.tsx @@ -0,0 +1,111 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { describe, it, expect, vi } from 'vitest'; +import { Pagination } from '@/components/common/Pagination'; + +describe('Pagination Component', () => { + it('renders page information and total item count', () => { + render( + + ); + + expect(screen.getByTestId('pagination-info')).toHaveTextContent('Showing 11 to 20 of 50 documents'); + expect(screen.getByText('Page 2 of 5')).toBeInTheDocument(); + }); + + it('handles navigation button clicks', () => { + const handlePageChange = vi.fn(); + render( + + ); + + const prevButton = screen.getByRole('button', { name: /previous page/i }); + const nextButton = screen.getByRole('button', { name: /next page/i }); + + fireEvent.click(prevButton); + expect(handlePageChange).toHaveBeenCalledWith(1); + + fireEvent.click(nextButton); + expect(handlePageChange).toHaveBeenCalledWith(3); + }); + + it('disables previous button on first page and next button on last page', () => { + const { rerender } = render( + + ); + + expect(screen.getByRole('button', { name: /previous page/i })).toBeDisabled(); + expect(screen.getByRole('button', { name: /next page/i })).not.toBeDisabled(); + + rerender( + + ); + + expect(screen.getByRole('button', { name: /previous page/i })).not.toBeDisabled(); + expect(screen.getByRole('button', { name: /next page/i })).toBeDisabled(); + }); + + it('renders page size selector and calls onPageSizeChange', () => { + const handlePageSizeChange = vi.fn(); + render( + + ); + + const select = screen.getByLabelText(/select rows per page for records/i); + expect(select).toBeInTheDocument(); + expect(select).toHaveValue('10'); + + fireEvent.change(select, { target: { value: '20' } }); + expect(handlePageSizeChange).toHaveBeenCalledWith(20); + }); + + it('handles 0 total items gracefully', () => { + render( + + ); + + expect(screen.getByTestId('pagination-info')).toHaveTextContent('Showing 0 to 0 of 0 items'); + expect(screen.getByRole('button', { name: /previous page/i })).toBeDisabled(); + expect(screen.getByRole('button', { name: /next page/i })).toBeDisabled(); + }); +}); diff --git a/corporate-platform/corporate-platform-web/src/components/common/Pagination.tsx b/corporate-platform/corporate-platform-web/src/components/common/Pagination.tsx new file mode 100644 index 00000000..b6f8d039 --- /dev/null +++ b/corporate-platform/corporate-platform-web/src/components/common/Pagination.tsx @@ -0,0 +1,121 @@ +'use client'; + +import React from 'react'; +import { ChevronLeft, ChevronRight } from 'lucide-react'; + +export interface PaginationProps { + page: number; + totalPages: number; + total?: number; + pageSize?: number; + pageSizeOptions?: number[]; + onPageChange: (page: number) => void; + onPageSizeChange?: (pageSize: number) => void; + itemLabel?: string; + compact?: boolean; + showPageSizeSelector?: boolean; + className?: string; + disabled?: boolean; +} + +export function Pagination({ + page, + totalPages, + total, + pageSize = 10, + pageSizeOptions = [10, 20, 50, 100], + onPageChange, + onPageSizeChange, + itemLabel = 'items', + compact = false, + showPageSizeSelector = false, + className = '', + disabled = false, +}: PaginationProps) { + const safeTotalPages = Math.max(1, totalPages); + const safePage = Math.min(Math.max(1, page), safeTotalPages); + + const from = total !== undefined ? (total === 0 ? 0 : (safePage - 1) * pageSize + 1) : undefined; + const to = total !== undefined ? Math.min(safePage * pageSize, total) : undefined; + + return ( +
+
+ {total !== undefined && from !== undefined && to !== undefined ? ( + + Showing {from} to{' '} + {to} of{' '} + {total} {itemLabel} + + ) : ( + + Page {safePage} of{' '} + {safeTotalPages} + + )} +
+ +
+ {showPageSizeSelector && onPageSizeChange && !compact && ( +
+ + Rows: + +
+ )} + +
+ + + + Page {safePage} of {safeTotalPages} + + + +
+
+
+ ); +} + +export default Pagination; diff --git a/corporate-platform/corporate-platform-web/src/components/common/Virtualization.test.tsx b/corporate-platform/corporate-platform-web/src/components/common/Virtualization.test.tsx new file mode 100644 index 00000000..a468c09a --- /dev/null +++ b/corporate-platform/corporate-platform-web/src/components/common/Virtualization.test.tsx @@ -0,0 +1,77 @@ +import React, { useRef } from 'react'; +import { render, screen } from '@testing-library/react'; +import { describe, it, expect, vi } from 'vitest'; +import { useVirtualizer } from '@tanstack/react-virtual'; + +interface Item { + id: string; + name: string; +} + +function VirtualizedTable({ items }: { items: Item[] }) { + const parentRef = useRef(null); + + const virtualizer = useVirtualizer({ + count: items.length, + getScrollElement: () => parentRef.current, + estimateSize: () => 50, + overscan: 2, + initialRect: { width: 800, height: 200 }, + }); + + const virtualRows = virtualizer.getVirtualItems(); + const totalSize = virtualizer.getTotalSize(); + const paddingTop = virtualRows.length > 0 ? virtualRows[0]?.start || 0 : 0; + const paddingBottom = + virtualRows.length > 0 + ? totalSize - (virtualRows[virtualRows.length - 1]?.end || 0) + : 0; + + return ( +
+ + + {paddingTop > 0 && ( + + + )} + {virtualRows.map((virtualRow) => { + const item = items[virtualRow.index]; + return ( + + + + ); + })} + {paddingBottom > 0 && ( + + + )} + +
+
{item.name}
+
+
+ ); +} + +describe('Row Virtualization Verification', () => { + it('renders a reduced subset of DOM nodes when total rows is large', () => { + const totalItemsCount = 200; + const items: Item[] = Array.from({ length: totalItemsCount }, (_, i) => ({ + id: `item-${i + 1}`, + name: `Row Item ${i + 1}`, + })); + + const { container } = render(); + + // In a 200px viewport with 50px item height and overscan 2, + // virtualizer renders ~4-8 items instead of all 200 items. + const renderedVirtualRows = screen.queryAllByTestId('virtual-row'); + const totalRenderedTr = container.querySelectorAll('tbody tr'); + + expect(renderedVirtualRows.length).toBeLessThan(totalItemsCount); + expect(renderedVirtualRows.length).toBeLessThanOrEqual(20); + expect(totalRenderedTr.length).toBeLessThan(totalItemsCount); + }); +}); diff --git a/corporate-platform/corporate-platform-web/src/components/ipfs/IpfsManager.test.tsx b/corporate-platform/corporate-platform-web/src/components/ipfs/IpfsManager.test.tsx index 2433eb16..932f25ee 100644 --- a/corporate-platform/corporate-platform-web/src/components/ipfs/IpfsManager.test.tsx +++ b/corporate-platform/corporate-platform-web/src/components/ipfs/IpfsManager.test.tsx @@ -136,120 +136,116 @@ describe('IpfsManager', () => { expect(await screen.findByText('Unable to load')).toBeInTheDocument() }) - // --------------------------------------------------------------------------- - // Chunked upload tests - // --------------------------------------------------------------------------- + it('supports paging through multiple pages of documents', async () => { + const manyDocs = Array.from({ length: 25 }, (_, i) => ({ + id: `doc-${i + 1}`, + companyId: 'company-1', + documentType: 'REPORT', + referenceId: `ref-${i + 1}`, + ipfsCid: `QmDoc${i + 1}`, + ipfsGateway: 'https://gateway.pinata.cloud/ipfs/', + fileName: `report-${i + 1}.pdf`, + fileSize: 100 + i, + mimeType: 'application/pdf', + pinned: true, + pinnedAt: '2026-01-01T00:00:00.000Z', + createdAt: '2026-01-01T00:00:00.000Z', + })) + + listDocumentsMock.mockResolvedValue({ success: true, data: manyDocs }) - it('uses small-file path (uploadDocument) for files below threshold', async () => { render() - await screen.findByText('report.pdf') - - const smallFile = new File(['small content'], 'small.pdf', { type: 'application/pdf' }) - const input = screen.getAllByRole('button', { name: 'Upload' })[0].closest('form')! - const fileInput = input.querySelector('input[type="file"]')! - - fireEvent.change(fileInput, { target: { files: [smallFile] } }) - fireEvent.click(screen.getByRole('button', { name: 'Upload' })) - - await waitFor(() => { - expect(uploadDocumentMock).toHaveBeenCalledWith(smallFile, expect.objectContaining({ companyId: 'company-1' })) - }) - - expect(chunkedUploadModule.useChunkedUpload().start).not.toHaveBeenCalled() - }) - it('uses chunked path for files at or above threshold', async () => { - const startMock = vi.fn().mockResolvedValue({ cid: 'QmChunked1' }) - vi.spyOn(chunkedUploadModule, 'useChunkedUpload').mockReturnValue(makeChunkedHook({ start: startMock })) + expect(await screen.findByText('report-1.pdf')).toBeInTheDocument() + expect(screen.getByText('report-10.pdf')).toBeInTheDocument() + expect(screen.queryByText('report-11.pdf')).not.toBeInTheDocument() - render() - await screen.findByText('report.pdf') - - // 10 MB + 1 byte — above threshold - const largeContent = new Uint8Array(10 * 1024 * 1024 + 1) - const largeFile = new File([largeContent], 'large.pdf', { type: 'application/pdf' }) - - const form = screen.getAllByRole('button', { name: 'Upload' })[0].closest('form')! - const fileInput = form.querySelector('input[type="file"]')! + // Showing 1 to 10 of 25 documents + expect(screen.getByText('Page 1 of 3')).toBeInTheDocument() - fireEvent.change(fileInput, { target: { files: [largeFile] } }) + // Click Next page + const nextBtn = screen.getByRole('button', { name: /next page/i }) + fireEvent.click(nextBtn) - expect(await screen.findByText(/resumable chunked upload/)).toBeInTheDocument() + expect(await screen.findByText('report-11.pdf')).toBeInTheDocument() + expect(screen.getByText('report-20.pdf')).toBeInTheDocument() + expect(screen.queryByText('report-1.pdf')).not.toBeInTheDocument() + expect(screen.getByText('Page 2 of 3')).toBeInTheDocument() - fireEvent.click(screen.getByRole('button', { name: 'Upload' })) + // Click Previous page + const prevBtn = screen.getByRole('button', { name: /previous page/i }) + fireEvent.click(prevBtn) - await waitFor(() => { - expect(startMock).toHaveBeenCalledWith(expect.objectContaining({ file: largeFile })) - }) - - expect(uploadDocumentMock).not.toHaveBeenCalled() + expect(await screen.findByText('report-1.pdf')).toBeInTheDocument() + expect(screen.getByText('Page 1 of 3')).toBeInTheDocument() }) - it('shows error and does not reload docs when chunked upload is cancelled', async () => { - const cancelMock = vi.fn() - const startMock = vi.fn().mockResolvedValue(null) // null = cancelled - vi.spyOn(chunkedUploadModule, 'useChunkedUpload').mockReturnValue( - makeChunkedHook({ start: startMock, cancel: cancelMock, error: 'Upload cancelled' }), - ) - - render() - await screen.findByText('report.pdf') + it('filters by uploadRef without regression and resets page', async () => { + const mixedDocs = [ + { + id: '1', + companyId: 'company-1', + documentType: 'REPORT', + referenceId: 'target-ref', + ipfsCid: 'QmDoc1', + fileName: 'target.pdf', + fileSize: 100, + mimeType: 'application/pdf', + pinned: true, + }, + { + id: '2', + companyId: 'company-1', + documentType: 'REPORT', + referenceId: 'other-ref', + ipfsCid: 'QmDoc2', + fileName: 'other.pdf', + fileSize: 200, + mimeType: 'application/pdf', + pinned: true, + }, + ] - const largeContent = new Uint8Array(10 * 1024 * 1024 + 1) - const largeFile = new File([largeContent], 'cancel.pdf', { type: 'application/pdf' }) + listDocumentsMock.mockResolvedValue({ success: true, data: mixedDocs }) - const form = screen.getAllByRole('button', { name: 'Upload' })[0].closest('form')! - const fileInput = form.querySelector('input[type="file"]')! + render() - fireEvent.change(fileInput, { target: { files: [largeFile] } }) - fireEvent.click(screen.getByRole('button', { name: 'Upload' })) + expect(await screen.findByText('target.pdf')).toBeInTheDocument() + expect(screen.getByText('other.pdf')).toBeInTheDocument() - await waitFor(() => { - expect(screen.getByText('Upload cancelled')).toBeInTheDocument() - }) + // Type into referenceId input in the single upload form + const refInput = screen.getByPlaceholderText('referenceId') + fireEvent.change(refInput, { target: { value: 'target-ref' } }) - // listDocuments should only have been called once on mount, not again after cancel - expect(listDocumentsMock).toHaveBeenCalledTimes(1) + expect(screen.getByText('target.pdf')).toBeInTheDocument() + expect(screen.queryByText('other.pdf')).not.toBeInTheDocument() + expect(screen.getByTestId('pagination-info')).toHaveTextContent('Showing 1 to 1 of 1 documents') }) - it('resumes from last byte offset when session exists in localStorage', async () => { - // Simulate a persisted session for the file - const sessions: Record = { - 'resume.pdf__10485761__0': { - sessionKey: 'resume.pdf__10485761__0', - fileName: 'resume.pdf', - fileSize: 10 * 1024 * 1024 + 1, - bytesUploaded: 4 * 1024 * 1024, // 4 MB already done - }, - } - localStorage.setItem('ipfs_upload_sessions', JSON.stringify(sessions)) - - const startMock = vi.fn().mockResolvedValue({ cid: 'QmResumed' }) - vi.spyOn(chunkedUploadModule, 'useChunkedUpload').mockReturnValue(makeChunkedHook({ start: startMock })) + it('renders and paginates large document sets', async () => { + const largeDocSet = Array.from({ length: 100 }, (_, i) => ({ + id: `doc-${i + 1}`, + companyId: 'company-1', + documentType: 'REPORT', + referenceId: `ref-${i + 1}`, + ipfsCid: `QmDoc${i + 1}`, + fileName: `doc-${i + 1}.pdf`, + fileSize: 100, + mimeType: 'application/pdf', + pinned: true, + })) + + listDocumentsMock.mockResolvedValue({ success: true, data: largeDocSet }) render() - await screen.findByText('report.pdf') - - const largeContent = new Uint8Array(10 * 1024 * 1024 + 1) - const largeFile = new File([largeContent], 'resume.pdf', { type: 'application/pdf' }) - Object.defineProperty(largeFile, 'lastModified', { value: 0 }) - - const form = screen.getAllByRole('button', { name: 'Upload' })[0].closest('form')! - const fileInput = form.querySelector('input[type="file"]')! - fireEvent.change(fileInput, { target: { files: [largeFile] } }) - fireEvent.click(screen.getByRole('button', { name: 'Upload' })) - - await waitFor(() => { - // The sessionKey is derived from file.name + size + lastModified; - // chunked.start should be called with that key - expect(startMock).toHaveBeenCalledWith( - expect.objectContaining({ sessionKey: 'resume.pdf__10485761__0' }), - ) - }) + expect(await screen.findByText('doc-1.pdf')).toBeInTheDocument() + expect(screen.getByTestId('pagination-info')).toHaveTextContent('Showing 1 to 10 of 100 documents') - expect(await screen.findByText(/QmResumed/)).toBeInTheDocument() + // Change page size to 20 + const pageSizeSelect = screen.getByLabelText(/select rows per page for documents/i) + fireEvent.change(pageSizeSelect, { target: { value: '20' } }) - localStorage.clear() + expect(screen.getByTestId('pagination-info')).toHaveTextContent('Showing 1 to 20 of 100 documents') }) }) diff --git a/corporate-platform/corporate-platform-web/src/components/ipfs/IpfsManager.tsx b/corporate-platform/corporate-platform-web/src/components/ipfs/IpfsManager.tsx index 26a07c20..65047c4c 100644 --- a/corporate-platform/corporate-platform-web/src/components/ipfs/IpfsManager.tsx +++ b/corporate-platform/corporate-platform-web/src/components/ipfs/IpfsManager.tsx @@ -13,9 +13,11 @@ import { Upload, X, } from 'lucide-react' +import { useVirtualizer } from '@tanstack/react-virtual' import { useAuth } from '@/contexts/AuthContext' import { ipfsService, CHUNKED_UPLOAD_THRESHOLD } from '@/services/ipfs.service' import { useChunkedUpload } from '@/hooks/useChunkedUpload' +import { Pagination } from '@/components/common/Pagination' import type { IpfsDocumentRecord, IpfsDocumentType } from '@/types/ipfs' const documentTypes: IpfsDocumentType[] = [ @@ -46,6 +48,10 @@ export default function IpfsManager() { const [error, setError] = useState(null) const [success, setSuccess] = useState(null) + const [page, setPage] = useState(1) + const [pageSize, setPageSize] = useState(10) + const tableContainerRef = useRef(null) + const [uploadFile, setUploadFile] = useState(null) const [uploadType, setUploadType] = useState('REPORT') const [uploadRef, setUploadRef] = useState('') @@ -79,6 +85,47 @@ export default function IpfsManager() { return documents.filter((doc) => doc.referenceId === uploadRef.trim()) }, [documents, uploadRef]) + const totalPages = Math.max(1, Math.ceil(filteredDocs.length / pageSize)) + const safePage = Math.min(Math.max(1, page), totalPages) + + const paginatedDocs = useMemo(() => { + const start = (safePage - 1) * pageSize + return filteredDocs.slice(start, start + pageSize) + }, [filteredDocs, safePage, pageSize]) + + const rowVirtualizer = useVirtualizer({ + count: paginatedDocs.length, + getScrollElement: () => tableContainerRef.current, + estimateSize: () => 48, + overscan: 5, + initialRect: { width: 1000, height: 400 }, + }) + + const virtualRows = rowVirtualizer.getVirtualItems() + const totalSize = rowVirtualizer.getTotalSize() + const paddingTop = virtualRows.length > 0 ? virtualRows[0]?.start || 0 : 0 + const paddingBottom = + virtualRows.length > 0 + ? totalSize - (virtualRows[virtualRows.length - 1]?.end || 0) + : 0 + + const rowsToRender = useMemo(() => { + if (virtualRows.length > 0) { + return virtualRows.map((vr) => ({ + index: vr.index, + key: vr.key, + doc: paginatedDocs[vr.index], + measureRef: rowVirtualizer.measureElement, + })) + } + return paginatedDocs.map((doc, idx) => ({ + index: idx, + key: doc.id || idx, + doc, + measureRef: undefined, + })) + }, [virtualRows, paginatedDocs, rowVirtualizer.measureElement]) + const loadDocuments = useCallback(async () => { setLoading(true) setError(null) @@ -91,7 +138,9 @@ export default function IpfsManager() { return } - setDocuments(response.data || []) + const data = response.data + const list = Array.isArray(data) ? data : (data as any)?.data || [] + setDocuments(list) setLoading(false) }, [user?.companyId]) @@ -613,47 +662,82 @@ export default function IpfsManager() {
- {/* Document Records */} -
-

Document Records

+
+
+

Document Records

+
{loading ? ( -
Loading documents...
+
Loading documents...
) : filteredDocs.length === 0 ? ( -
No documents found for the current filter/company.
+
No documents found for the current filter/company.
) : ( -
- - - - - - - - - - - - - {filteredDocs.map((doc) => ( - - - - - - - +
+
+
TypeReferenceFileCIDPinnedActions
{doc.documentType}{doc.referenceId}{doc.fileName}{doc.ipfsCid}{doc.pinned ? 'Yes' : 'No'} - -
+ + + + + + + + - ))} - -
TypeReferenceFileCIDPinnedActions
+ + + {paddingTop > 0 && ( + + + + )} + {rowsToRender.map(({ index, key, doc, measureRef }) => { + if (!doc) return null + return ( + + {doc.documentType} + {doc.referenceId} + {doc.fileName} + {doc.ipfsCid} + {doc.pinned ? 'Yes' : 'No'} + + + + + ) + })} + {paddingBottom > 0 && ( + + + + )} + + +
+ + setPage(newPage)} + onPageSizeChange={(newSize) => { + setPageSize(newSize) + setPage(1) + }} + showPageSizeSelector + />
)}
diff --git a/corporate-platform/corporate-platform-web/src/contexts/CorporateContext.tsx b/corporate-platform/corporate-platform-web/src/contexts/CorporateContext.tsx index d99e5cd1..e95bf6b9 100644 --- a/corporate-platform/corporate-platform-web/src/contexts/CorporateContext.tsx +++ b/corporate-platform/corporate-platform-web/src/contexts/CorporateContext.tsx @@ -7,6 +7,13 @@ import { useCompliance } from '@/hooks/useCompliance' import { ComplianceReport, ComplianceStatusItem, ComplianceFramework } from '@/types' import { useHydrated } from '@/hooks/useHydrated' +export interface PortfolioHoldingsPagination { + total: number + page: number + pageSize: number + pages: number +} + interface CorporateContextType { company: any credits: any[] @@ -15,8 +22,11 @@ interface CorporateContextType { portfolioSummary: PortfolioSummaryMetrics | null portfolioAnalytics: PortfolioAnalytics | null portfolioHoldings: PortfolioHolding[] + portfolioHoldingsPagination: PortfolioHoldingsPagination portfolioLoading: boolean portfolioError: string | null + fetchPortfolioHoldings: (params?: { page?: number; pageSize?: number }) => Promise + setHoldingsPage: (page: number, pageSize?: number) => Promise selectedCredit: any | null setSelectedCredit: (credit: any) => void addToCart: (credit: any) => void @@ -47,6 +57,12 @@ export function CorporateProvider({ children }: { children: ReactNode }) { const [portfolioSummary, setPortfolioSummary] = useState(null) const [portfolioAnalytics, setPortfolioAnalytics] = useState(null) const [portfolioHoldings, setPortfolioHoldings] = useState([]) + const [portfolioHoldingsPagination, setPortfolioHoldingsPagination] = useState({ + total: 0, + page: 1, + pageSize: 20, + pages: 1, + }) const [portfolioLoading, setPortfolioLoading] = useState(false) const [portfolioError, setPortfolioError] = useState(null) const [selectedCredit, setSelectedCredit] = useState(null) @@ -58,6 +74,36 @@ export function CorporateProvider({ children }: { children: ReactNode }) { const [complianceReport, setComplianceReport] = useState(null) const [complianceStatuses, setComplianceStatuses] = useState(null) + const fetchPortfolioHoldings = async (params?: { page?: number; pageSize?: number }) => { + const targetPage = params?.page ?? portfolioHoldingsPagination.page + const targetPageSize = params?.pageSize ?? portfolioHoldingsPagination.pageSize + setPortfolioLoading(true) + setPortfolioError(null) + try { + const holdingsRes = await portfolioService.getHoldings({ page: targetPage, pageSize: targetPageSize }) + if (holdingsRes.success && holdingsRes.data) { + const raw = holdingsRes.data + const holdingsList = Array.isArray(raw) ? raw : (raw.data || []) + const total = raw && typeof raw === 'object' && 'total' in raw ? (raw.total ?? holdingsList.length) : holdingsList.length + const page = raw && typeof raw === 'object' && 'page' in raw ? (raw.page ?? targetPage) : targetPage + const pageSize = raw && typeof raw === 'object' && 'pageSize' in raw ? (raw.pageSize ?? targetPageSize) : targetPageSize + const pages = raw && typeof raw === 'object' && 'pages' in raw ? (raw.pages ?? Math.max(1, Math.ceil(total / pageSize))) : Math.max(1, Math.ceil(total / pageSize)) + + setPortfolioHoldings(holdingsList) + setPortfolioHoldingsPagination({ total, page, pageSize, pages }) + } else { + setPortfolioError(holdingsRes.error || 'Failed to load holdings') + } + } catch (err: any) { + setPortfolioError(err?.message || 'Portfolio API error') + } finally { + setPortfolioLoading(false) + } + } + + const setHoldingsPage = async (page: number, pageSize?: number) => { + await fetchPortfolioHoldings({ page, pageSize }) + } // Fetch portfolio data on mount - only runs on client useEffect(() => { @@ -76,8 +122,19 @@ export function CorporateProvider({ children }: { children: ReactNode }) { else setPortfolioError(summaryRes.error || 'Failed to load summary'); if (analyticsRes.success) setPortfolioAnalytics(analyticsRes.data!); else setPortfolioError(analyticsRes.error || 'Failed to load analytics'); - if (holdingsRes.success) setPortfolioHoldings(holdingsRes.data?.data || []); - else setPortfolioError(holdingsRes.error || 'Failed to load holdings'); + if (holdingsRes.success && holdingsRes.data) { + const raw = holdingsRes.data; + const holdingsList = Array.isArray(raw) ? raw : (raw.data || []); + const total = raw && typeof raw === 'object' && 'total' in raw ? (raw.total ?? holdingsList.length) : holdingsList.length; + const page = raw && typeof raw === 'object' && 'page' in raw ? (raw.page ?? 1) : 1; + const pageSize = raw && typeof raw === 'object' && 'pageSize' in raw ? (raw.pageSize ?? 20) : 20; + const pages = raw && typeof raw === 'object' && 'pages' in raw ? (raw.pages ?? Math.max(1, Math.ceil(total / pageSize))) : Math.max(1, Math.ceil(total / pageSize)); + + setPortfolioHoldings(holdingsList); + setPortfolioHoldingsPagination({ total, page, pageSize, pages }); + } else { + setPortfolioError(holdingsRes.error || 'Failed to load holdings'); + } }) .catch((err) => setPortfolioError(err.message || 'Portfolio API error')) .finally(() => setPortfolioLoading(false)); @@ -126,8 +183,11 @@ export function CorporateProvider({ children }: { children: ReactNode }) { portfolioSummary, portfolioAnalytics, portfolioHoldings, + portfolioHoldingsPagination, portfolioLoading, portfolioError, + fetchPortfolioHoldings, + setHoldingsPage, selectedCredit, setSelectedCredit, addToCart, diff --git a/corporate-platform/corporate-platform-web/src/hooks/usePagination.test.ts b/corporate-platform/corporate-platform-web/src/hooks/usePagination.test.ts new file mode 100644 index 00000000..e47e35b0 --- /dev/null +++ b/corporate-platform/corporate-platform-web/src/hooks/usePagination.test.ts @@ -0,0 +1,90 @@ +import { renderHook, act } from '@testing-library/react'; +import { describe, it, expect, vi } from 'vitest'; +import { usePagination } from '@/hooks/usePagination'; + +describe('usePagination', () => { + it('initializes with default values', () => { + const { result } = renderHook(() => + usePagination({ totalItems: 45, initialPage: 1, initialPageSize: 10 }) + ); + + expect(result.current.page).toBe(1); + expect(result.current.pageSize).toBe(10); + expect(result.current.totalPages).toBe(5); + expect(result.current.startIndex).toBe(0); + expect(result.current.endIndex).toBe(10); + expect(result.current.fromItem).toBe(1); + expect(result.current.toItem).toBe(10); + expect(result.current.hasNextPage).toBe(true); + expect(result.current.hasPrevPage).toBe(false); + }); + + it('navigates through pages', () => { + const { result } = renderHook(() => + usePagination({ totalItems: 30, initialPage: 1, initialPageSize: 10 }) + ); + + act(() => { + result.current.nextPage(); + }); + + expect(result.current.page).toBe(2); + expect(result.current.hasPrevPage).toBe(true); + expect(result.current.hasNextPage).toBe(true); + + act(() => { + result.current.nextPage(); + }); + + expect(result.current.page).toBe(3); + expect(result.current.hasNextPage).toBe(false); + + act(() => { + result.current.prevPage(); + }); + + expect(result.current.page).toBe(2); + }); + + it('changes page size and resets page to 1', () => { + const onChange = vi.fn(); + const { result } = renderHook(() => + usePagination({ totalItems: 50, initialPage: 3, initialPageSize: 10, onChange }) + ); + + act(() => { + result.current.setPageSize(25); + }); + + expect(result.current.pageSize).toBe(25); + expect(result.current.page).toBe(1); + expect(result.current.totalPages).toBe(2); + expect(onChange).toHaveBeenCalledWith(1, 25); + }); + + it('slices array items correctly with paginateItems', () => { + const items = Array.from({ length: 25 }, (_, i) => `item-${i + 1}`); + const { result } = renderHook(() => + usePagination({ totalItems: items.length, initialPage: 2, initialPageSize: 10 }) + ); + + const sliced = result.current.paginateItems(items); + expect(sliced).toEqual(items.slice(10, 20)); + }); + + it('jumps to first and last page', () => { + const { result } = renderHook(() => + usePagination({ totalItems: 100, initialPage: 5, initialPageSize: 10 }) + ); + + act(() => { + result.current.firstPage(); + }); + expect(result.current.page).toBe(1); + + act(() => { + result.current.lastPage(); + }); + expect(result.current.page).toBe(10); + }); +}); diff --git a/corporate-platform/corporate-platform-web/src/hooks/usePagination.ts b/corporate-platform/corporate-platform-web/src/hooks/usePagination.ts new file mode 100644 index 00000000..a0a19a8d --- /dev/null +++ b/corporate-platform/corporate-platform-web/src/hooks/usePagination.ts @@ -0,0 +1,120 @@ +import { useState, useMemo, useCallback, useEffect } from 'react'; + +export interface UsePaginationOptions { + totalItems?: number; + initialPage?: number; + initialPageSize?: number; + pageSizeOptions?: number[]; + onChange?: (page: number, pageSize: number) => void; +} + +export interface UsePaginationReturn { + page: number; + pageSize: number; + totalPages: number; + totalItems: number; + startIndex: number; + endIndex: number; + fromItem: number; + toItem: number; + hasNextPage: boolean; + hasPrevPage: boolean; + setPage: (page: number | ((prev: number) => number)) => void; + setPageSize: (pageSize: number) => void; + nextPage: () => void; + prevPage: () => void; + firstPage: () => void; + lastPage: () => void; + paginateItems: (items: T[]) => T[]; +} + +export function usePagination({ + totalItems = 0, + initialPage = 1, + initialPageSize = 10, + onChange, +}: UsePaginationOptions = {}): UsePaginationReturn { + const [page, setPageState] = useState(initialPage); + const [pageSize, setPageSizeState] = useState(initialPageSize); + + const totalPages = Math.max(1, Math.ceil(totalItems / pageSize)); + const safePage = Math.min(Math.max(1, page), totalPages); + + // If page is out of bounds after totalItems decreases, adjust it + useEffect(() => { + if (page > totalPages && totalPages > 0) { + setPageState(totalPages); + } + }, [page, totalPages]); + + const setPage = useCallback( + (newPageOrFn: number | ((prev: number) => number)) => { + setPageState((prev) => { + const next = typeof newPageOrFn === 'function' ? newPageOrFn(prev) : newPageOrFn; + const bounded = Math.min(Math.max(1, next), totalPages); + onChange?.(bounded, pageSize); + return bounded; + }); + }, + [totalPages, pageSize, onChange], + ); + + const setPageSize = useCallback( + (newSize: number) => { + setPageSizeState(newSize); + setPageState(1); + onChange?.(1, newSize); + }, + [onChange], + ); + + const nextPage = useCallback(() => { + setPage((p) => Math.min(totalPages, p + 1)); + }, [totalPages, setPage]); + + const prevPage = useCallback(() => { + setPage((p) => Math.max(1, p - 1)); + }, [setPage]); + + const firstPage = useCallback(() => { + setPage(1); + }, [setPage]); + + const lastPage = useCallback(() => { + setPage(totalPages); + }, [totalPages, setPage]); + + const startIndex = (safePage - 1) * pageSize; + const endIndex = Math.min(startIndex + pageSize, totalItems); + const fromItem = totalItems === 0 ? 0 : startIndex + 1; + const toItem = Math.min(safePage * pageSize, totalItems); + + const paginateItems = useCallback( + (items: T[]): T[] => { + return items.slice(startIndex, startIndex + pageSize); + }, + [startIndex, pageSize], + ); + + return { + page: safePage, + pageSize, + totalPages, + totalItems, + startIndex, + endIndex, + fromItem, + toItem, + hasNextPage: safePage < totalPages, + hasPrevPage: safePage > 1, + setPage, + setPageSize, + nextPage, + prevPage, + firstPage, + lastPage, + paginateItems, + }; +} + +export default usePagination; diff --git a/corporate-platform/corporate-platform-web/src/services/ipfs.service.test.ts b/corporate-platform/corporate-platform-web/src/services/ipfs.service.test.ts index 08befd5f..b1a7fc95 100644 --- a/corporate-platform/corporate-platform-web/src/services/ipfs.service.test.ts +++ b/corporate-platform/corporate-platform-web/src/services/ipfs.service.test.ts @@ -27,6 +27,22 @@ describe('IpfsService', () => { expect(mockGet).toHaveBeenCalledWith('/ipfs/documents?companyId=company-1'); }); + it('lists documents with company scope and pagination params', async () => { + mockGet.mockResolvedValue({ success: true, data: [] }); + + await ipfsService.listDocuments('company-1', { page: 2, limit: 10 }); + + expect(mockGet).toHaveBeenCalledWith('/ipfs/documents?companyId=company-1&page=2&limit=10'); + }); + + it('lists documents with params object', async () => { + mockGet.mockResolvedValue({ success: true, data: [] }); + + await ipfsService.listDocuments({ companyId: 'company-2', page: 3, limit: 25 }); + + expect(mockGet).toHaveBeenCalledWith('/ipfs/documents?companyId=company-2&page=3&limit=25'); + }); + it('gets documents by reference id', async () => { mockGet.mockResolvedValue({ success: true, data: [] }); diff --git a/corporate-platform/corporate-platform-web/src/services/ipfs.service.ts b/corporate-platform/corporate-platform-web/src/services/ipfs.service.ts index ba133ff5..aff8d6ee 100644 --- a/corporate-platform/corporate-platform-web/src/services/ipfs.service.ts +++ b/corporate-platform/corporate-platform-web/src/services/ipfs.service.ts @@ -173,10 +173,31 @@ class IpfsService { return this.normalizeResponse(response); } - async listDocuments(companyId?: string): Promise> { - const endpoint = companyId - ? `/ipfs/documents?companyId=${encodeURIComponent(companyId)}` - : '/ipfs/documents'; + async listDocuments( + companyIdOrParams?: string | { companyId?: string; page?: number; limit?: number }, + pagination?: { page?: number; limit?: number }, + ): Promise> { + let companyId: string | undefined; + let page: number | undefined; + let limit: number | undefined; + + if (typeof companyIdOrParams === 'string') { + companyId = companyIdOrParams; + page = pagination?.page; + limit = pagination?.limit; + } else if (companyIdOrParams && typeof companyIdOrParams === 'object') { + companyId = companyIdOrParams.companyId; + page = companyIdOrParams.page; + limit = companyIdOrParams.limit; + } + + const queryParams = new URLSearchParams(); + if (companyId) queryParams.set('companyId', companyId); + if (page !== undefined && page !== null) queryParams.set('page', String(page)); + if (limit !== undefined && limit !== null) queryParams.set('limit', String(limit)); + + const qs = queryParams.toString(); + const endpoint = qs ? `/ipfs/documents?${qs}` : '/ipfs/documents'; const response = await apiClient.get(endpoint); return this.normalizeResponse(response); } diff --git a/project-portal/project-portal-backend/cmd/api/health.go b/project-portal/project-portal-backend/cmd/api/health.go new file mode 100644 index 00000000..54457401 --- /dev/null +++ b/project-portal/project-portal-backend/cmd/api/health.go @@ -0,0 +1,114 @@ +package main + +import ( + "context" + "net/http" + "sync" + "time" + + "carbon-scribe/project-portal/project-portal-backend/pkg/elastic" + + "github.com/gin-gonic/gin" + "go.mongodb.org/mongo-driver/mongo" + "gorm.io/gorm" +) + +// HealthHandler returns a gin.HandlerFunc that performs live health checks on Postgres, Elasticsearch, and MongoDB. +func HealthHandler(db *gorm.DB, esClient *elastic.Client, mongoClient *mongo.Client) gin.HandlerFunc { + return func(c *gin.Context) { + const probeTimeout = 2 * time.Second + + var ( + pgStatus = "healthy" + esStatus = "healthy" + mongoStatus = "healthy" + wg sync.WaitGroup + mu sync.Mutex + ) + + wg.Add(3) + + // Probe PostgreSQL connectivity + go func() { + defer wg.Done() + status := "healthy" + if db == nil { + status = "unhealthy" + } else { + sqlDB, err := db.DB() + if err != nil { + status = "unhealthy" + } else { + ctx, cancel := context.WithTimeout(c.Request.Context(), probeTimeout) + defer cancel() + if err := sqlDB.PingContext(ctx); err != nil { + status = "unhealthy" + } + } + } + mu.Lock() + pgStatus = status + mu.Unlock() + }() + + // Probe Elasticsearch connectivity using existing esClient.Health(ctx) + go func() { + defer wg.Done() + status := "healthy" + if esClient == nil { + status = "unhealthy" + } else { + ctx, cancel := context.WithTimeout(c.Request.Context(), probeTimeout) + defer cancel() + if err := esClient.Health(ctx); err != nil { + status = "unhealthy" + } + } + mu.Lock() + esStatus = status + mu.Unlock() + }() + + // Probe MongoDB connectivity using mongoClient.Ping(ctx, nil) + go func() { + defer wg.Done() + status := "healthy" + if mongoClient == nil { + status = "unhealthy" + } else { + ctx, cancel := context.WithTimeout(c.Request.Context(), probeTimeout) + defer cancel() + if err := mongoClient.Ping(ctx, nil); err != nil { + status = "unhealthy" + } + } + mu.Lock() + mongoStatus = status + mu.Unlock() + }() + + wg.Wait() + + allHealthy := pgStatus == "healthy" && esStatus == "healthy" && mongoStatus == "healthy" + + overallStatus := "healthy" + httpStatus := http.StatusOK + if !allHealthy { + overallStatus = "unhealthy" + httpStatus = http.StatusServiceUnavailable + } + + c.JSON(httpStatus, gin.H{ + "status": overallStatus, + "service": "carbon-scribe-project-portal", + "timestamp": time.Now().Format(time.RFC3339), + "version": "1.0.0", + "modules": []string{"auth", "collaboration", "documents", "integration", "reports", "search", "geospatial", "settings", "financing", "inventory", "notifications", "monitoring"}, + "dependencies": gin.H{ + "postgres": pgStatus, + "elasticsearch": esStatus, + "mongodb": mongoStatus, + }, + }) + } +} diff --git a/project-portal/project-portal-backend/cmd/api/health_test.go b/project-portal/project-portal-backend/cmd/api/health_test.go new file mode 100644 index 00000000..1407cf63 --- /dev/null +++ b/project-portal/project-portal-backend/cmd/api/health_test.go @@ -0,0 +1,343 @@ +package main + +import ( + "context" + "database/sql" + "database/sql/driver" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "sync" + "testing" + "time" + + "carbon-scribe/project-portal/project-portal-backend/pkg/elastic" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" + "gorm.io/driver/postgres" + "gorm.io/gorm" +) + +type mockSQLDriver struct { + shouldFailPing bool + mu sync.Mutex +} + +func (d *mockSQLDriver) Open(name string) (driver.Conn, error) { + return &mockSQLConn{driver: d}, nil +} + +type mockSQLConn struct { + driver *mockSQLDriver +} + +func (c *mockSQLConn) Prepare(query string) (driver.Stmt, error) { + return nil, errors.New("not implemented") +} + +func (c *mockSQLConn) Close() error { + return nil +} + +func (c *mockSQLConn) Begin() (driver.Tx, error) { + return nil, errors.New("not implemented") +} + +func (c *mockSQLConn) Ping(ctx context.Context) error { + c.driver.mu.Lock() + defer c.driver.mu.Unlock() + if c.driver.shouldFailPing { + return errors.New("database ping error") + } + return nil +} + +func (c *mockSQLConn) ResetSession(ctx context.Context) error { + return nil +} + +var ( + registerDriverOnce sync.Once + testDriver = &mockSQLDriver{} +) + +func initMockDB(t *testing.T) *gorm.DB { + registerDriverOnce.Do(func() { + sql.Register("mock_sql_driver", testDriver) + }) + + testDriver.mu.Lock() + testDriver.shouldFailPing = false + testDriver.mu.Unlock() + + sqlDB, err := sql.Open("mock_sql_driver", "") + require.NoError(t, err) + + gormDB, err := gorm.Open(postgres.New(postgres.Config{ + Conn: sqlDB, + }), &gorm.Config{}) + require.NoError(t, err) + + return gormDB +} + +type HealthResponse struct { + Status string `json:"status"` + Service string `json:"service"` + Timestamp string `json:"timestamp"` + Version string `json:"version"` + Modules []string `json:"modules"` + Dependencies map[string]string `json:"dependencies"` +} + +func setupTestGin() *gin.Engine { + gin.SetMode(gin.TestMode) + return gin.New() +} + +func TestHealthHandler_AllNilDependencies(t *testing.T) { + router := setupTestGin() + router.GET("/health", HealthHandler(nil, nil, nil)) + + w := httptest.NewRecorder() + req, err := http.NewRequest(http.MethodGet, "/health", nil) + require.NoError(t, err) + + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusServiceUnavailable, w.Code) + + var resp HealthResponse + err = json.Unmarshal(w.Body.Bytes(), &resp) + require.NoError(t, err) + + assert.Equal(t, "unhealthy", resp.Status) + assert.Equal(t, "carbon-scribe-project-portal", resp.Service) + assert.Equal(t, "1.0.0", resp.Version) + assert.NotEmpty(t, resp.Timestamp) + assert.NotEmpty(t, resp.Modules) + + assert.Equal(t, "unhealthy", resp.Dependencies["postgres"]) + assert.Equal(t, "unhealthy", resp.Dependencies["elasticsearch"]) + assert.Equal(t, "unhealthy", resp.Dependencies["mongodb"]) +} + +func TestHealthHandler_HealthyPostgres_UnhealthyOthers(t *testing.T) { + db := initMockDB(t) + + router := setupTestGin() + router.GET("/health", HealthHandler(db, nil, nil)) + + w := httptest.NewRecorder() + req, err := http.NewRequest(http.MethodGet, "/health", nil) + require.NoError(t, err) + + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusServiceUnavailable, w.Code) + + var resp HealthResponse + err = json.Unmarshal(w.Body.Bytes(), &resp) + require.NoError(t, err) + + assert.Equal(t, "unhealthy", resp.Status) + assert.Equal(t, "healthy", resp.Dependencies["postgres"]) + assert.Equal(t, "unhealthy", resp.Dependencies["elasticsearch"]) + assert.Equal(t, "unhealthy", resp.Dependencies["mongodb"]) +} + +func TestHealthHandler_FailingPostgres(t *testing.T) { + db := initMockDB(t) + + // Make ping fail + testDriver.mu.Lock() + testDriver.shouldFailPing = true + testDriver.mu.Unlock() + + router := setupTestGin() + router.GET("/health", HealthHandler(db, nil, nil)) + + w := httptest.NewRecorder() + req, err := http.NewRequest(http.MethodGet, "/health", nil) + require.NoError(t, err) + + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusServiceUnavailable, w.Code) + + var resp HealthResponse + err = json.Unmarshal(w.Body.Bytes(), &resp) + require.NoError(t, err) + + assert.Equal(t, "unhealthy", resp.Status) + assert.Equal(t, "unhealthy", resp.Dependencies["postgres"]) +} + +func TestHealthHandler_ElasticsearchMock(t *testing.T) { + esServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("X-Elastic-Product", "Elasticsearch") + if r.URL.Path == "/" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"version":{"number":"8.19.1"}}`)) + return + } + if r.URL.Path == "/_cluster/health" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"cluster_name":"test-cluster","status":"green"}`)) + return + } + w.WriteHeader(http.StatusNotFound) + })) + defer esServer.Close() + + esClient, err := elastic.NewClient(elastic.Config{ + Addresses: []string{esServer.URL}, + }) + require.NoError(t, err) + + db := initMockDB(t) + + router := setupTestGin() + router.GET("/health", HealthHandler(db, esClient, nil)) + + w := httptest.NewRecorder() + req, err := http.NewRequest(http.MethodGet, "/health", nil) + require.NoError(t, err) + + router.ServeHTTP(w, req) + + // MongoDB is nil, so overall is unhealthy (503), but ES and PG are healthy + assert.Equal(t, http.StatusServiceUnavailable, w.Code) + + var resp HealthResponse + err = json.Unmarshal(w.Body.Bytes(), &resp) + require.NoError(t, err) + + assert.Equal(t, "healthy", resp.Dependencies["postgres"]) + assert.Equal(t, "healthy", resp.Dependencies["elasticsearch"]) + assert.Equal(t, "unhealthy", resp.Dependencies["mongodb"]) +} + +func TestHealthHandler_ElasticsearchFailing(t *testing.T) { + esServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("X-Elastic-Product", "Elasticsearch") + if r.URL.Path == "/" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"version":{"number":"8.19.1"}}`)) + return + } + if r.URL.Path == "/_cluster/health" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(`{"error":"internal server error"}`)) + return + } + w.WriteHeader(http.StatusNotFound) + })) + defer esServer.Close() + + esClient, err := elastic.NewClient(elastic.Config{ + Addresses: []string{esServer.URL}, + }) + require.NoError(t, err) + + router := setupTestGin() + router.GET("/health", HealthHandler(nil, esClient, nil)) + + w := httptest.NewRecorder() + req, err := http.NewRequest(http.MethodGet, "/health", nil) + require.NoError(t, err) + + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusServiceUnavailable, w.Code) + + var resp HealthResponse + err = json.Unmarshal(w.Body.Bytes(), &resp) + require.NoError(t, err) + + assert.Equal(t, "unhealthy", resp.Dependencies["elasticsearch"]) +} + +func TestHealthHandler_MongoDBUnhealthy(t *testing.T) { + // Point to unreachable MongoDB instance with 100ms server selection timeout + ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond) + defer cancel() + + mongoClient, err := mongo.Connect(ctx, options.Client().ApplyURI("mongodb://127.0.0.1:54329/?serverSelectionTimeoutMS=100")) + require.NoError(t, err) + + router := setupTestGin() + router.GET("/health", HealthHandler(nil, nil, mongoClient)) + + w := httptest.NewRecorder() + req, err := http.NewRequest(http.MethodGet, "/health", nil) + require.NoError(t, err) + + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusServiceUnavailable, w.Code) + + var resp HealthResponse + err = json.Unmarshal(w.Body.Bytes(), &resp) + require.NoError(t, err) + + assert.Equal(t, "unhealthy", resp.Dependencies["mongodb"]) +} + +func TestHealthHandler_TimeoutBounded(t *testing.T) { + // Mock slow Elasticsearch server that hangs + esServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("X-Elastic-Product", "Elasticsearch") + if r.URL.Path == "/" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"version":{"number":"8.19.1"}}`)) + return + } + if r.URL.Path == "/_cluster/health" { + select { + case <-time.After(5 * time.Second): + case <-r.Context().Done(): + } + w.WriteHeader(http.StatusOK) + return + } + w.WriteHeader(http.StatusNotFound) + })) + defer esServer.Close() + + esClient, err := elastic.NewClient(elastic.Config{ + Addresses: []string{esServer.URL}, + }) + require.NoError(t, err) + + router := setupTestGin() + router.GET("/health", HealthHandler(nil, esClient, nil)) + + start := time.Now() + w := httptest.NewRecorder() + req, err := http.NewRequest(http.MethodGet, "/health", nil) + require.NoError(t, err) + + router.ServeHTTP(w, req) + elapsed := time.Since(start) + + // Must finish around probeTimeout (2s), definitely less than the 5s sleep + assert.Less(t, elapsed, 4*time.Second) + assert.Equal(t, http.StatusServiceUnavailable, w.Code) + + var resp HealthResponse + err = json.Unmarshal(w.Body.Bytes(), &resp) + require.NoError(t, err) + + assert.Equal(t, "unhealthy", resp.Dependencies["elasticsearch"]) +} diff --git a/project-portal/project-portal-backend/cmd/api/main.go b/project-portal/project-portal-backend/cmd/api/main.go index 4ea0a5a1..2b81d58a 100644 --- a/project-portal/project-portal-backend/cmd/api/main.go +++ b/project-portal/project-portal-backend/cmd/api/main.go @@ -298,15 +298,7 @@ func main() { router.Use(corsMiddleware()) // Health check endpoint - router.GET("/health", func(c *gin.Context) { - c.JSON(http.StatusOK, gin.H{ - "status": "healthy", - "service": "carbon-scribe-project-portal", - "timestamp": time.Now().Format(time.RFC3339), - "version": "1.0.0", - "modules": []string{"auth", "collaboration", "documents", "integration", "reports", "search", "geospatial", "settings", "financing", "inventory", "notifications", "monitoring"}, - }) - }) + router.GET("/health", HealthHandler(db, esClient, notificationMongoClient)) // Root API route router.GET("/", func(c *gin.Context) { diff --git a/project-portal/project-portal-backend/pkg/elastic/client.go b/project-portal/project-portal-backend/pkg/elastic/client.go index fd563e08..c20eb04e 100644 --- a/project-portal/project-portal-backend/pkg/elastic/client.go +++ b/project-portal/project-portal-backend/pkg/elastic/client.go @@ -159,7 +159,7 @@ func (c *Client) Search(ctx context.Context, indexName string, query interface{} // Health checks the cluster health func (c *Client) Health(ctx context.Context) error { - res, err := c.es.Cluster.Health() + res, err := c.es.Cluster.Health(c.es.Cluster.Health.WithContext(ctx)) if err != nil { return fmt.Errorf("error getting health: %w", err) }