diff --git a/packages/backend/src/analytics/analytics.controller.ts b/packages/backend/src/analytics/analytics.controller.ts index f1e494a..2cbb4d0 100644 --- a/packages/backend/src/analytics/analytics.controller.ts +++ b/packages/backend/src/analytics/analytics.controller.ts @@ -23,6 +23,7 @@ import { UserStakesQueryDto, UserStakesResponseDto, } from './dto/user-stakes.dto'; +import { TotalValueLockedResponseDto } from './dto/tvl.dto'; @ApiTags('Analytics') @Controller('users') @@ -108,4 +109,29 @@ export class AnalyticsController { const limit = query.limit ?? 20; return this.analyticsService.getUserStakes(address, page, limit); } + + /** + * GET /analytics/:userAddress/tvl + * + * Returns the total XLM value locked in all unresolved (Pending) stakes + * for the given wallet address. + */ + @Get(':userAddress/tvl') + @ApiOperation({ + summary: 'Get Total Value Locked', + description: + "Sums the amounts of every stake whose underlying call is still PENDING. " + + "This represents the user's active capital that has not yet been resolved.", + }) + @ApiParam({ name: 'userAddress', description: 'Stellar wallet address of the user' }) + @ApiResponse({ + status: 200, + description: 'Total value locked successfully aggregated', + type: TotalValueLockedResponseDto, + }) + getTotalValueLocked( + @Param('userAddress') userAddress: string, + ): Promise { + return this.analyticsService.getTotalValueLocked(userAddress); + } } \ No newline at end of file diff --git a/packages/backend/src/analytics/analytics.service.spec.ts b/packages/backend/src/analytics/analytics.service.spec.ts index 9abc310..58ac1a0 100644 --- a/packages/backend/src/analytics/analytics.service.spec.ts +++ b/packages/backend/src/analytics/analytics.service.spec.ts @@ -1,18 +1,88 @@ import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; import { AnalyticsService } from './analytics.service'; +import { Stake } from './entities/stake.entity'; -describe('AnalyticsService', () => { +const mockQb = { + innerJoin: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + select: jest.fn().mockReturnThis(), + addSelect: jest.fn().mockReturnThis(), + getRawOne: jest.fn(), +}; + +const mockStakeLedgerRepository = { + createQueryBuilder: jest.fn(() => mockQb), +}; + +describe('AnalyticsService – getTotalValueLocked', () => { let service: AnalyticsService; beforeEach(async () => { + // Reset call counts between tests without recreating the chain references + jest.clearAllMocks(); + mockStakeLedgerRepository.createQueryBuilder.mockReturnValue(mockQb); + const module: TestingModule = await Test.createTestingModule({ - providers: [AnalyticsService], + providers: [ + AnalyticsService, + { + provide: getRepositoryToken(Stake), + useValue: mockStakeLedgerRepository, + }, + ], }).compile(); service = module.get(AnalyticsService); }); - it('should be defined', () => { - expect(service).toBeDefined(); + it('returns correct TVL and count when pending stakes exist', async () => { + mockQb.getRawOne.mockResolvedValue({ + totalValueLocked: '1250.75', + pendingStakesCount: '8', + }); + + const result = await service.getTotalValueLocked('GBXXX'); + + expect(result).toEqual({ + userAddress: 'GBXXX', + totalValueLocked: 1250.75, + pendingStakesCount: 8, + }); + + // Assert the query was scoped correctly + expect(mockQb.where).toHaveBeenCalledWith( + 'stake.userAddress = :userAddress', + { userAddress: 'GBXXX' }, + ); + expect(mockQb.andWhere).toHaveBeenCalledWith( + 'call.outcome = :outcome', + { outcome: 'PENDING' }, + ); + }); + + it('returns zeros when the user has no pending stakes', async () => { + // DB returns COALESCE default — still a string from getRawOne + mockQb.getRawOne.mockResolvedValue({ + totalValueLocked: '0', + pendingStakesCount: '0', + }); + + const result = await service.getTotalValueLocked('GBYYY'); + + expect(result.totalValueLocked).toBe(0); + expect(result.pendingStakesCount).toBe(0); + expect(result.userAddress).toBe('GBYYY'); + }); + + it('handles null getRawOne result gracefully', async () => { + // Edge case: getRawOne can return undefined if the driver returns nothing + mockQb.getRawOne.mockResolvedValue(undefined); + + const result = await service.getTotalValueLocked('GBZZZ'); + + expect(result.totalValueLocked).toBe(0); + expect(result.pendingStakesCount).toBe(0); }); -}); +}); \ No newline at end of file diff --git a/packages/backend/src/analytics/analytics.service.ts b/packages/backend/src/analytics/analytics.service.ts index 784b275..a3a097c 100644 --- a/packages/backend/src/analytics/analytics.service.ts +++ b/packages/backend/src/analytics/analytics.service.ts @@ -1,5 +1,6 @@ -import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; +import { TotalValueLockedResponseDto } from './dto/tvl.dto'; +import { Injectable } from '@nestjs/common'; import { DataSource, Repository } from 'typeorm'; import { CACHE_MANAGER } from '@nestjs/cache-manager'; import { Cache } from 'cache-manager'; @@ -27,10 +28,12 @@ export class AnalyticsService { private readonly callRepository: Repository, @InjectRepository(Stake) private readonly stakeRepository: Repository, + @InjectRepository(Stake) + private readonly stakeLedgerRepository: Repository, private readonly dataSource: DataSource, @Inject(CACHE_MANAGER) private cacheManager: Cache, - ) { } + ) {} /** * Get a paginated ledger of a user's stakes joined with call info. @@ -430,4 +433,32 @@ export class AnalyticsService { return Number(reputation.toFixed(4)); } + + /** + * Aggregates a user's active Portfolio "Total Value Locked". + * + * Loops over every StakeLedger row where: + * - userAddress matches the caller + * - the parent Call still has outcome === 'PENDING' (i.e. unresolved) + * + * Returns the XLM sum of those amounts and a count of matching rows. + */ + async getTotalValueLocked( + userAddress: string, + ): Promise { + const result = await this.stakeLedgerRepository + .createQueryBuilder('stake') + .innerJoin('stake.call', 'call') + .where('stake.userAddress = :userAddress', { userAddress }) + .andWhere('call.outcome = :outcome', { outcome: 'PENDING' }) + .select('COALESCE(SUM(stake.amount), 0)', 'totalValueLocked') + .addSelect('COUNT(stake.id)', 'pendingStakesCount') + .getRawOne<{ totalValueLocked: string; pendingStakesCount: string }>(); + + return { + userAddress, + totalValueLocked: parseFloat(result?.totalValueLocked ?? '0'), + pendingStakesCount: parseInt(result?.pendingStakesCount ?? '0', 10), + }; + } } diff --git a/packages/backend/src/analytics/dto/tvl.dto.ts b/packages/backend/src/analytics/dto/tvl.dto.ts new file mode 100644 index 0000000..4bee11e --- /dev/null +++ b/packages/backend/src/analytics/dto/tvl.dto.ts @@ -0,0 +1,21 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class TotalValueLockedResponseDto { + @ApiProperty({ + description: 'Wallet address the TVL was calculated for', + example: 'GBXXX...', + }) + userAddress: string; + + @ApiProperty({ + description: 'Sum of all Pending stake amounts for this user, in XLM', + example: 1250.75, + }) + totalValueLocked: number; + + @ApiProperty({ + description: 'Number of individual Pending stakes included in the sum', + example: 8, + }) + pendingStakesCount: number; +} \ No newline at end of file