diff --git a/.github/workflows/branch-ci.yaml b/.github/workflows/branch-ci.yaml new file mode 100644 index 00000000..b0964aea --- /dev/null +++ b/.github/workflows/branch-ci.yaml @@ -0,0 +1,36 @@ +name: Branch CI + +on: + push: + branches-ignore: + - main + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + lint-and-test: + runs-on: ubuntu-latest + + steps: + - name: Check out repository + uses: actions/checkout@v6 + + - name: Set up Node.js + uses: actions/setup-node@v6 + with: + node-version: 24 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Run lint + run: npm run lint + + - name: Run tests + run: npm run test + + - name: Run build + run: npm run build diff --git a/package.json b/package.json index c2c85cf3..a194afa0 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,8 @@ "start:dev": "nest start --watch", "start:debug": "nest start --debug --watch", "start:prod": "node dist/src/main", - "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", + "lint": "eslint \"{src,apps,libs,test}/**/*.ts\"", + "lint:fix": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", "test": "jest", "test:watch": "jest --watch", "test:cov": "jest --coverage", diff --git a/src/account/controllers/accounts.controller.spec.ts b/src/account/controllers/accounts.controller.spec.ts new file mode 100644 index 00000000..d84ebe50 --- /dev/null +++ b/src/account/controllers/accounts.controller.spec.ts @@ -0,0 +1,105 @@ +import { AccountsController } from './accounts.controller'; +import { paginate } from 'nestjs-typeorm-paginate'; +import { NotFoundException } from '@nestjs/common'; + +jest.mock('nestjs-typeorm-paginate', () => ({ + paginate: jest.fn().mockResolvedValue({ items: [], meta: {} }), +})); + +describe('AccountsController', () => { + const createQueryBuilder = () => ({ + leftJoin: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + }); + + let controller: AccountsController; + let accountRepository: { + createQueryBuilder: jest.Mock; + findOne: jest.Mock; + update: jest.Mock; + }; + let queryBuilder: ReturnType; + let accountService: { + getChainNameForAccount: jest.Mock; + }; + let profileReadService: { + getProfile: jest.Mock; + }; + + beforeEach(() => { + queryBuilder = createQueryBuilder(); + accountRepository = { + createQueryBuilder: jest.fn(() => queryBuilder), + findOne: jest.fn(), + update: jest.fn().mockResolvedValue(undefined), + }; + accountService = { + getChainNameForAccount: jest.fn(), + }; + profileReadService = { + getProfile: jest.fn(), + }; + + controller = new AccountsController( + accountRepository as any, + {} as any, + accountService as any, + profileReadService as any, + ); + }); + + it('returns paginated accounts', async () => { + const result = await controller.listAll( + undefined, + 1, + 100, + 'total_volume', + 'DESC', + ); + + expect(accountRepository.createQueryBuilder).toHaveBeenCalledWith( + 'account', + ); + expect(queryBuilder.leftJoin).not.toHaveBeenCalled(); + expect(queryBuilder.orderBy).toHaveBeenCalledWith( + 'account.total_volume', + 'DESC', + ); + expect(paginate).toHaveBeenCalledWith(queryBuilder, { + page: 1, + limit: 100, + }); + expect(result).toEqual({ items: [], meta: {} }); + }); + + it('applies search across account addresses and names', async () => { + await controller.listAll('alice', 1, 100, 'total_volume', 'DESC'); + + expect(queryBuilder.leftJoin).toHaveBeenCalledWith( + expect.any(Function), + 'profile_cache', + 'profile_cache.address = account.address', + ); + expect(queryBuilder.andWhere).toHaveBeenCalledTimes(1); + const [whereClause, params] = queryBuilder.andWhere.mock.calls[0]; + + expect(whereClause).toBeDefined(); + expect(params).toBeUndefined(); + }); + + it('does not apply search for blank input', async () => { + await controller.listAll(' ', 1, 100, 'total_volume', 'DESC'); + + expect(queryBuilder.leftJoin).not.toHaveBeenCalled(); + expect(queryBuilder.andWhere).not.toHaveBeenCalled(); + }); + + it('throws when account is missing', async () => { + accountRepository.findOne.mockResolvedValue(null); + + await expect(controller.getAccount('missing')).rejects.toBeInstanceOf( + NotFoundException, + ); + }); +}); diff --git a/src/account/controllers/accounts.controller.ts b/src/account/controllers/accounts.controller.ts index 62dff695..f63020ab 100644 --- a/src/account/controllers/accounts.controller.ts +++ b/src/account/controllers/accounts.controller.ts @@ -20,13 +20,14 @@ import { import { InjectRepository } from '@nestjs/typeorm'; import moment from 'moment'; import { paginate } from 'nestjs-typeorm-paginate'; -import { Repository } from 'typeorm'; +import { Brackets, Repository } from 'typeorm'; import { Account } from '../entities/account.entity'; import { PortfolioService } from '../services/portfolio.service'; import { AccountService } from '../services/account.service'; import { GetPortfolioHistoryQueryDto } from '../dto/get-portfolio-history-query.dto'; import { PortfolioHistorySnapshotDto } from '../dto/portfolio-history-response.dto'; import { ProfileReadService } from '@/profile/services/profile-read.service'; +import { ProfileCache } from '@/profile/entities/profile-cache.entity'; @UseInterceptors(CacheInterceptor) @Controller('accounts') @@ -46,6 +47,12 @@ export class AccountsController { @ApiQuery({ name: 'page', type: 'number', required: false }) @ApiQuery({ name: 'limit', type: 'number', required: false }) + @ApiQuery({ + name: 'search', + type: 'string', + required: false, + description: 'Search accounts by address or name', + }) @ApiQuery({ name: 'order_by', enum: [ @@ -65,12 +72,45 @@ export class AccountsController { @ApiOperation({ operationId: 'listAll' }) @Get() async listAll( + @Query('search') search: string | undefined, @Query('page', new DefaultValuePipe(1), ParseIntPipe) page = 1, @Query('limit', new DefaultValuePipe(100), ParseIntPipe) limit = 100, @Query('order_by') orderBy: string = 'total_volume', @Query('order_direction') orderDirection: 'ASC' | 'DESC' = 'DESC', ) { const query = this.accountRepository.createQueryBuilder('account'); + + if (search?.trim()) { + const normalizedSearch = `%${search.trim()}%`; + query.leftJoin( + ProfileCache, + 'profile_cache', + 'profile_cache.address = account.address', + ); + query.andWhere( + new Brackets((qb) => { + qb.where('account.address ILIKE :search', { + search: normalizedSearch, + }) + .orWhere('account.chain_name ILIKE :search', { + search: normalizedSearch, + }) + .orWhere('profile_cache.public_name ILIKE :search', { + search: normalizedSearch, + }) + .orWhere('profile_cache.chain_name ILIKE :search', { + search: normalizedSearch, + }) + .orWhere('profile_cache.username ILIKE :search', { + search: normalizedSearch, + }) + .orWhere('profile_cache.fullname ILIKE :search', { + search: normalizedSearch, + }); + }), + ); + } + if (orderBy) { query.orderBy(`account.${orderBy}`, orderDirection); } diff --git a/src/account/services/bcl-pnl.service.spec.ts b/src/account/services/bcl-pnl.service.spec.ts index 546d690c..487ba59b 100644 --- a/src/account/services/bcl-pnl.service.spec.ts +++ b/src/account/services/bcl-pnl.service.spec.ts @@ -127,10 +127,7 @@ describe('BclPnlService', () => { }, ]); - const result = await service.calculateTokenPnlsBatch( - 'ak_test', - [200, 300], - ); + const result = await service.calculateTokenPnlsBatch('ak_test', [200, 300]); // Single DB round-trip regardless of number of heights expect(transactionRepository.query).toHaveBeenCalledTimes(1); @@ -144,7 +141,9 @@ describe('BclPnlService', () => { expect(sql).not.toContain('JOIN transactions tx'); // no repeated scan of transactions expect(sql).toContain('LEFT JOIN LATERAL'); expect(sql).toContain('LIMIT 1'); - expect(sql).not.toContain('DISTINCT ON (agg.snapshot_height, agg.sale_address)'); + expect(sql).not.toContain( + 'DISTINCT ON (agg.snapshot_height, agg.sale_address)', + ); expect(params).toEqual(['ak_test', [200, 300]]); expect(result).toBeInstanceOf(Map); diff --git a/src/affiliation/controllers/affiliation.controller.ts b/src/affiliation/controllers/affiliation.controller.ts index 953b7077..ef03f231 100644 --- a/src/affiliation/controllers/affiliation.controller.ts +++ b/src/affiliation/controllers/affiliation.controller.ts @@ -1,10 +1,5 @@ import { Controller, Post, Body, Param, Get, Render } from '@nestjs/common'; -import { - ApiBody, - ApiOperation, - ApiResponse, - ApiTags, -} from '@nestjs/swagger'; +import { ApiBody, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { CreateAffiliationDto } from '../dto/create-affiliation.dto'; diff --git a/src/affiliation/services/bcl-affiliation-analytics.service.spec.ts b/src/affiliation/services/bcl-affiliation-analytics.service.spec.ts index aeeeec20..03e04730 100644 --- a/src/affiliation/services/bcl-affiliation-analytics.service.spec.ts +++ b/src/affiliation/services/bcl-affiliation-analytics.service.spec.ts @@ -12,12 +12,10 @@ describe('BclAffiliationAnalyticsService', () => { releaseFirstBatch = resolve; }); - jest - .spyOn(service as any, 'parseDateRange') - .mockReturnValue({ - startDate: new Date('2026-03-01T00:00:00.000Z'), - endDate: new Date('2026-03-04T00:00:00.000Z'), - }); + jest.spyOn(service as any, 'parseDateRange').mockReturnValue({ + startDate: new Date('2026-03-01T00:00:00.000Z'), + endDate: new Date('2026-03-04T00:00:00.000Z'), + }); jest .spyOn(service as any, 'getDailyRegisteredCounts') .mockImplementation(async () => { @@ -36,7 +34,10 @@ describe('BclAffiliationAnalyticsService', () => { await firstBatchGate; return {}; }); - const getXVerificationData = jest.spyOn(service as any, 'getXVerificationData'); + const getXVerificationData = jest.spyOn( + service as any, + 'getXVerificationData', + ); getXVerificationData.mockResolvedValue({ series: [], summary: { total_verified_users: 0 }, diff --git a/src/api-core/base/base.resolver.ts b/src/api-core/base/base.resolver.ts index dd783f9a..249bd3e3 100644 --- a/src/api-core/base/base.resolver.ts +++ b/src/api-core/base/base.resolver.ts @@ -8,7 +8,7 @@ import { Parent, } from '@nestjs/graphql'; import { InjectRepository, getRepositoryToken } from '@nestjs/typeorm'; -import { Inject, Optional } from '@nestjs/common'; +import { Inject, Optional, Type } from '@nestjs/common'; import { Repository } from 'typeorm'; import { paginate } from 'nestjs-typeorm-paginate'; import { PaginatedResponse } from '../types/pagination.type'; @@ -25,7 +25,7 @@ export function createBaseResolver(config: EntityConfig) { @Resolver(() => config.entity) class BaseResolver { public readonly repository: Repository; - public readonly relatedRepositories: Map>; + public readonly relatedRepositories: Map, Repository>; constructor( repository: Repository, @@ -36,7 +36,7 @@ export function createBaseResolver(config: EntityConfig) { @Optional() repo4?: Repository, ) { this.repository = repository; - this.relatedRepositories = new Map>(); + this.relatedRepositories = new Map, Repository>(); // Map related repositories by entity type (only use the ones that exist) const repos = [repo0, repo1, repo2, repo3, repo4]; diff --git a/src/app.controller.spec.ts b/src/app.controller.spec.ts index 9a6399f8..de1e40d0 100644 --- a/src/app.controller.spec.ts +++ b/src/app.controller.spec.ts @@ -38,12 +38,10 @@ describe('AppController', () => { { provide: CommunityFactoryService, useValue: { - getCurrentFactory: jest - .fn() - .mockResolvedValue({ - address: 'ct_123', - deployed_at_block_height: 123, - }), + getCurrentFactory: jest.fn().mockResolvedValue({ + address: 'ct_123', + deployed_at_block_height: 123, + }), }, }, { @@ -115,7 +113,10 @@ describe('AppController', () => { const result = await appController.getFactory(); expect(communityFactoryService.getCurrentFactory).toHaveBeenCalled(); expect(result).toEqual( - expect.objectContaining({ address: 'ct_123', deployed_at_block_height: 123 }), + expect.objectContaining({ + address: 'ct_123', + deployed_at_block_height: 123, + }), ); }); }); diff --git a/src/configs/social.ts b/src/configs/social.ts index 2da9840f..4c92dfab 100644 --- a/src/configs/social.ts +++ b/src/configs/social.ts @@ -4,7 +4,8 @@ export const GOOGLE_CLIENT_ID = process.env.GOOGLE_CLIENT_ID; export const GOOGLE_CLIENT_SECRET = process.env.GOOGLE_CLIENT_SECRET; export const X_CLIENT_ID = process.env.X_CLIENT_ID; export const X_CLIENT_SECRET = process.env.X_CLIENT_SECRET; -export const X_CLIENT_SCOPE = process.env.X_CLIENT_SCOPE || 'users.read tweet.read'; +export const X_CLIENT_SCOPE = + process.env.X_CLIENT_SCOPE || 'users.read tweet.read'; export const X_CLIENT_TYPE = process.env.X_CLIENT_TYPE || 'third_party_app'; export const X_API_KEY = process.env.X_API_KEY; export const X_API_KEY_SECRET = process.env.X_API_KEY_SECRET; diff --git a/src/dex/controllers/dex-tokens.controller.spec.ts b/src/dex/controllers/dex-tokens.controller.spec.ts new file mode 100644 index 00000000..104a8bbd --- /dev/null +++ b/src/dex/controllers/dex-tokens.controller.spec.ts @@ -0,0 +1,37 @@ +import { DexTokensController } from './dex-tokens.controller'; + +describe('DexTokensController', () => { + let controller: DexTokensController; + let dexTokenService: { + findAll: jest.Mock; + findByAddress: jest.Mock; + getTokenPrice: jest.Mock; + getTokenPriceWithLiquidityAnalysis: jest.Mock; + }; + + beforeEach(() => { + dexTokenService = { + findAll: jest.fn().mockResolvedValue({ items: [], meta: {} }), + findByAddress: jest.fn(), + getTokenPrice: jest.fn(), + getTokenPriceWithLiquidityAnalysis: jest.fn(), + }; + + controller = new DexTokensController( + dexTokenService as any, + {} as any, + {} as any, + ); + }); + + it('forwards search params to dexTokenService.findAll', async () => { + await controller.listAll(3, 20, 'wae', 'price', 'ASC'); + + expect(dexTokenService.findAll).toHaveBeenCalledWith( + { page: 3, limit: 20 }, + 'wae', + 'price', + 'ASC', + ); + }); +}); diff --git a/src/dex/controllers/pairs.controller.spec.ts b/src/dex/controllers/pairs.controller.spec.ts new file mode 100644 index 00000000..705b6858 --- /dev/null +++ b/src/dex/controllers/pairs.controller.spec.ts @@ -0,0 +1,34 @@ +import { PairsController } from './pairs.controller'; + +describe('PairsController', () => { + let controller: PairsController; + let pairService: { + findAll: jest.Mock; + findByAddress: jest.Mock; + findByFromTokenAndToToken: jest.Mock; + findSwapPaths: jest.Mock; + }; + + beforeEach(() => { + pairService = { + findAll: jest.fn().mockResolvedValue({ items: [], meta: {} }), + findByAddress: jest.fn(), + findByFromTokenAndToToken: jest.fn(), + findSwapPaths: jest.fn(), + }; + + controller = new PairsController(pairService as any, {} as any, {} as any); + }); + + it('forwards search params to pairService.findAll', async () => { + await controller.listAll('alice', 'ct_token', 2, 25, 'created_at', 'ASC'); + + expect(pairService.findAll).toHaveBeenCalledWith( + { page: 2, limit: 25 }, + 'created_at', + 'ASC', + 'alice', + 'ct_token', + ); + }); +}); diff --git a/src/main.validation.spec.ts b/src/main.validation.spec.ts index c40ae909..c2a49213 100644 --- a/src/main.validation.spec.ts +++ b/src/main.validation.spec.ts @@ -164,11 +164,10 @@ describe('global ValidationPipe request DTO coverage', () => { }); it.each(cases)('rejects unexpected fields for $name', async (testCase) => { - const payload = - testCase.extraPayload ?? { - ...testCase.validPayload, - unexpected_field: 'boom', - }; + const payload = testCase.extraPayload ?? { + ...testCase.validPayload, + unexpected_field: 'boom', + }; await expect( pipe @@ -181,7 +180,9 @@ describe('global ValidationPipe request DTO coverage', () => { throw error.getResponse?.() ?? error; }), ).rejects.toMatchObject({ - message: expect.arrayContaining([expect.stringMatching(/should not exist/)]), + message: expect.arrayContaining([ + expect.stringMatching(/should not exist/), + ]), }); }); }); diff --git a/src/mdw-sync/services/block-sync.service.spec.ts b/src/mdw-sync/services/block-sync.service.spec.ts index 3672f233..f5db5d37 100644 --- a/src/mdw-sync/services/block-sync.service.spec.ts +++ b/src/mdw-sync/services/block-sync.service.spec.ts @@ -170,9 +170,9 @@ describe('BlockSyncService', () => { new Error('timeout exceeded when trying to connect'), ); - await expect(service.syncTransactions(123, 123, true, true)).rejects.toThrow( - 'timeout exceeded when trying to connect', - ); + await expect( + service.syncTransactions(123, 123, true, true), + ).rejects.toThrow('timeout exceeded when trying to connect'); expect(txRepository.save).not.toHaveBeenCalled(); expect(pluginBatchProcessor.processBatch).not.toHaveBeenCalled(); diff --git a/src/mdw-sync/services/block-sync.service.ts b/src/mdw-sync/services/block-sync.service.ts index 191da739..00c5f89a 100644 --- a/src/mdw-sync/services/block-sync.service.ts +++ b/src/mdw-sync/services/block-sync.service.ts @@ -476,5 +476,4 @@ export class BlockSyncService { created_at: new Date(tx.microTime), // Explicitly set timestamp }; } - } diff --git a/src/plugins/bcl/services/transaction-processor.service.spec.ts b/src/plugins/bcl/services/transaction-processor.service.spec.ts index 50bcaab7..41665a7a 100644 --- a/src/plugins/bcl/services/transaction-processor.service.spec.ts +++ b/src/plugins/bcl/services/transaction-processor.service.spec.ts @@ -174,7 +174,10 @@ describe('TransactionProcessorService', () => { tokenHolderService.updateTokenHolder.mockResolvedValue(undefined); tokenService.updateTokenTrendingScore.mockResolvedValue(undefined); - await service.processTransaction(rawTransaction as any, SyncDirectionEnum.Live); + await service.processTransaction( + rawTransaction as any, + SyncDirectionEnum.Live, + ); expect(tokenService.updateTokenTrendingScore).toHaveBeenCalledWith(token); }); diff --git a/src/plugins/bcl/services/transaction-processor.service.ts b/src/plugins/bcl/services/transaction-processor.service.ts index 818eb502..c8e506f3 100644 --- a/src/plugins/bcl/services/transaction-processor.service.ts +++ b/src/plugins/bcl/services/transaction-processor.service.ts @@ -89,10 +89,11 @@ export class TransactionProcessorService { return null; } - transactionToken = await this.tokenService.createTokenFromRawTransaction( - rawTransaction, - manager, - ); + transactionToken = + await this.tokenService.createTokenFromRawTransaction( + rawTransaction, + manager, + ); if (!transactionToken) { this.logger.warn( `Skipping create_community tx ${rawTransaction.hash}: failed to create token for ${saleAddress}`, diff --git a/src/plugins/bcl/services/transaction-validation.service.ts b/src/plugins/bcl/services/transaction-validation.service.ts index ec74be34..2b26f20c 100644 --- a/src/plugins/bcl/services/transaction-validation.service.ts +++ b/src/plugins/bcl/services/transaction-validation.service.ts @@ -73,8 +73,7 @@ export class TransactionValidationService { const amountAe = transaction.amount?.ae?.toString(); const unitPriceAe = transaction.unit_price?.ae?.toString(); - const previousBuyPriceAe = - transaction.previous_buy_price?.ae?.toString(); + const previousBuyPriceAe = transaction.previous_buy_price?.ae?.toString(); const buyPriceAe = transaction.buy_price?.ae?.toString(); const marketCapAe = transaction.market_cap?.ae?.toString(); @@ -85,10 +84,9 @@ export class TransactionValidationService { marketCapAe, ].some((value) => value === 'NaN'); - return !transaction.verified && ( - hasZeroVolume || - amountAe === '0' || - hasInvalidPriceData + return ( + !transaction.verified && + (hasZeroVolume || amountAe === '0' || hasInvalidPriceData) ); } @@ -132,7 +130,8 @@ export class TransactionValidationService { // create_community must come from current factory contract if (tx.function === BCL_FUNCTIONS.create_community) { - const currentFactory = await this.communityFactoryService.getCurrentFactory(); + const currentFactory = + await this.communityFactoryService.getCurrentFactory(); if (tx.contract_id !== currentFactory.address) { return { isValid: false, saleAddress: null }; } diff --git a/src/plugins/bcl/services/transactions.service.ts b/src/plugins/bcl/services/transactions.service.ts index 43f5bdf4..983e9972 100644 --- a/src/plugins/bcl/services/transactions.service.ts +++ b/src/plugins/bcl/services/transactions.service.ts @@ -154,8 +154,9 @@ export class TransactionsService { await this.communityFactoryService.getCurrentFactory(); const mintLogs = logs.filter((log) => log?.topics?.[0] === this.MINT_TOPIC); const protocolRewardMintLog = - mintLogs.find((log) => log?.address === currentFactory.bctsl_aex9_address) || - mintLogs.find((log) => log?.topics?.[2]?.toString() !== volumeRaw); + mintLogs.find( + (log) => log?.address === currentFactory.bctsl_aex9_address, + ) || mintLogs.find((log) => log?.topics?.[2]?.toString() !== volumeRaw); const protocolRewardRaw = protocolRewardMintLog?.topics?.[2]?.toString(); if (!protocolRewardRaw) { diff --git a/src/plugins/social-tipping/services/social-tipping-transaction-processor.service.spec.ts b/src/plugins/social-tipping/services/social-tipping-transaction-processor.service.spec.ts index d098dda3..60bebb1e 100644 --- a/src/plugins/social-tipping/services/social-tipping-transaction-processor.service.spec.ts +++ b/src/plugins/social-tipping/services/social-tipping-transaction-processor.service.spec.ts @@ -45,16 +45,20 @@ describe('SocialTippingTransactionProcessorService', () => { postRepository.findOne.mockResolvedValue({ token_mentions: ['BETA'], }); - tokensService.updateTrendingScoresForSymbols.mockImplementation(async () => { - callOrder.push('recalculate'); - }); + tokensService.updateTrendingScoresForSymbols.mockImplementation( + async () => { + callOrder.push('recalculate'); + }, + ); - tipRepository.manager.transaction.mockImplementation(async (handler: any) => { - callOrder.push('transaction-start'); - const result = await handler({}); - callOrder.push('transaction-commit'); - return result; - }); + tipRepository.manager.transaction.mockImplementation( + async (handler: any) => { + callOrder.push('transaction-start'); + const result = await handler({}); + callOrder.push('transaction-commit'); + return result; + }, + ); const result = await service.processTransaction( { @@ -111,7 +115,9 @@ describe('SocialTippingTransactionProcessorService', () => { jest .spyOn(service as any, 'validateTransaction') .mockReturnValue('TIP_POST:post-1'); - jest.spyOn(service as any, 'saveTipFromTransaction').mockResolvedValue(null); + jest + .spyOn(service as any, 'saveTipFromTransaction') + .mockResolvedValue(null); tipRepository.manager.transaction.mockImplementation(async (handler: any) => handler({}), ); @@ -152,7 +158,10 @@ describe('SocialTippingTransactionProcessorService', () => { }), }; - const ensureAccountExists = jest.spyOn(service as any, 'ensureAccountExists'); + const ensureAccountExists = jest.spyOn( + service as any, + 'ensureAccountExists', + ); const result = await (service as any).saveTipFromTransaction( { @@ -198,7 +207,10 @@ describe('SocialTippingTransactionProcessorService', () => { }), }; - const ensureAccountExists = jest.spyOn(service as any, 'ensureAccountExists'); + const ensureAccountExists = jest.spyOn( + service as any, + 'ensureAccountExists', + ); const result = await (service as any).saveTipFromTransaction( { diff --git a/src/plugins/social/services/post-transaction-processor.service.spec.ts b/src/plugins/social/services/post-transaction-processor.service.spec.ts index d61daba0..8bc44025 100644 --- a/src/plugins/social/services/post-transaction-processor.service.spec.ts +++ b/src/plugins/social/services/post-transaction-processor.service.spec.ts @@ -79,7 +79,9 @@ describe('PostTransactionProcessorService', () => { topics: [], media: [], }); - persistenceService.validatePostData.mockImplementation((postData: any) => postData); + persistenceService.validatePostData.mockImplementation( + (postData: any) => postData, + ); persistenceService.savePost.mockResolvedValue(savedPost); topicManagementService.updateTopicPostCounts.mockResolvedValue(undefined); tokensService.updateTrendingScoresForSymbols.mockRejectedValue( diff --git a/src/profile/controllers/profile-rewards.controller.spec.ts b/src/profile/controllers/profile-rewards.controller.spec.ts index 58d9a9c0..3e5e686c 100644 --- a/src/profile/controllers/profile-rewards.controller.spec.ts +++ b/src/profile/controllers/profile-rewards.controller.spec.ts @@ -48,7 +48,9 @@ describe('ProfileRewardsController', () => { it('verifies challenge proof before running manual recheck', async () => { const profileXInviteService = { - verifyPostingRewardRecheckChallenge: jest.fn().mockResolvedValue(undefined), + verifyPostingRewardRecheckChallenge: jest + .fn() + .mockResolvedValue(undefined), } as any; const profileXPostingRewardService = { requestManualRecheck: jest.fn().mockResolvedValue({ status: 'pending' }), @@ -72,8 +74,8 @@ describe('ProfileRewardsController', () => { expiresAt: 123, signatureHex: 'b'.repeat(128), }); - expect(profileXPostingRewardService.requestManualRecheck).toHaveBeenCalledWith( - 'ak_1', - ); + expect( + profileXPostingRewardService.requestManualRecheck, + ).toHaveBeenCalledWith('ak_1'); }); }); diff --git a/src/profile/controllers/profile-rewards.controller.ts b/src/profile/controllers/profile-rewards.controller.ts index 41ef4816..d97ac0b6 100644 --- a/src/profile/controllers/profile-rewards.controller.ts +++ b/src/profile/controllers/profile-rewards.controller.ts @@ -41,7 +41,8 @@ export class ProfileRewardsController { @UseGuards(RateLimitGuard) @ApiOperation({ operationId: 'recheckXPostingReward', - summary: 'Verify wallet ownership and run an on-demand X posting reward recheck', + summary: + 'Verify wallet ownership and run an on-demand X posting reward recheck', }) async recheckXPostingReward( @Param('address') address: string, diff --git a/src/profile/controllers/profile.controller.ts b/src/profile/controllers/profile.controller.ts index cc3a6ad2..597c4341 100644 --- a/src/profile/controllers/profile.controller.ts +++ b/src/profile/controllers/profile.controller.ts @@ -214,7 +214,9 @@ export class ProfileController { claimed_invitations: totalClaimedAsInviter, revoked_invitations: totalRevokedAsInviter, pending_invitations: - totalInvitationsAsInviter - totalClaimedAsInviter - totalRevokedAsInviter, + totalInvitationsAsInviter - + totalClaimedAsInviter - + totalRevokedAsInviter, total_amount_ae: Number(amountAgg?.total || 0), }, as_invitee: { diff --git a/src/profile/profile.constants.ts b/src/profile/profile.constants.ts index 71e63a2c..9def46fd 100644 --- a/src/profile/profile.constants.ts +++ b/src/profile/profile.constants.ts @@ -82,10 +82,12 @@ export const PROFILE_X_POSTING_REWARD_ENABLE_PERIODIC_RECHECKS = .trim() .toLowerCase() !== 'false'; -export const PROFILE_X_POSTING_REWARD_MANUAL_RECHECK_COOLDOWN_SECONDS = parseInt( - process.env.PROFILE_X_POSTING_REWARD_MANUAL_RECHECK_COOLDOWN_SECONDS || '3600', - 10, -); +export const PROFILE_X_POSTING_REWARD_MANUAL_RECHECK_COOLDOWN_SECONDS = + parseInt( + process.env.PROFILE_X_POSTING_REWARD_MANUAL_RECHECK_COOLDOWN_SECONDS || + '3600', + 10, + ); export const PROFILE_X_POSTING_REWARD_KEYWORDS = ( process.env.PROFILE_X_POSTING_REWARD_KEYWORDS || diff --git a/src/profile/services/profile-contract.service.spec.ts b/src/profile/services/profile-contract.service.spec.ts index e147939a..422c5f19 100644 --- a/src/profile/services/profile-contract.service.spec.ts +++ b/src/profile/services/profile-contract.service.spec.ts @@ -16,7 +16,9 @@ jest.mock('@aeternity/aepp-sdk', () => { describe('ProfileContractService', () => { const setup = () => { - const initializeContract = (Contract.initialize as jest.Mock).mockResolvedValue({ + const initializeContract = ( + Contract.initialize as jest.Mock + ).mockResolvedValue({ get_profile: jest.fn().mockResolvedValue({ decodedResult: null }), } as any); const aeSdkService = { diff --git a/src/profile/services/profile-indexer.service.spec.ts b/src/profile/services/profile-indexer.service.spec.ts index 0d5890fe..fc6b9788 100644 --- a/src/profile/services/profile-indexer.service.spec.ts +++ b/src/profile/services/profile-indexer.service.spec.ts @@ -135,7 +135,12 @@ describe('ProfileIndexerService', () => { ).toHaveBeenCalledTimes(1); expect( profileXPostingRewardService.upsertVerifiedCandidateFromTx, - ).toHaveBeenCalledWith('ak_verified', 'verified', '201', 'th_pending_then_confirmed'); + ).toHaveBeenCalledWith( + 'ak_verified', + 'verified', + '201', + 'th_pending_then_confirmed', + ); expect( profileXVerificationRewardService.sendRewardIfEligible, ).toHaveBeenCalledTimes(1); diff --git a/src/profile/services/profile-spend-queue.service.ts b/src/profile/services/profile-spend-queue.service.ts index 0fe16c91..d537af7c 100644 --- a/src/profile/services/profile-spend-queue.service.ts +++ b/src/profile/services/profile-spend-queue.service.ts @@ -8,7 +8,10 @@ export class ProfileSpendQueueService { private readonly accountsByKey = new Map(); private readonly accountInitErrorsByKey = new Map(); - async enqueueSpend(privateKey: string, work: () => Promise): Promise { + async enqueueSpend( + privateKey: string, + work: () => Promise, + ): Promise { const currentQueue = this.queuesByKey.get(privateKey) || Promise.resolve(); const current = currentQueue.then(work, work); this.queuesByKey.set( @@ -21,7 +24,10 @@ export class ProfileSpendQueueService { return current; } - getRewardAccount(privateKey: string, privateKeyEnvName: string): MemoryAccount { + getRewardAccount( + privateKey: string, + privateKeyEnvName: string, + ): MemoryAccount { const cached = this.accountsByKey.get(privateKey); if (cached) { return cached; @@ -32,7 +38,10 @@ export class ProfileSpendQueueService { } try { - const normalized = this.normalizePrivateKey(privateKey, privateKeyEnvName); + const normalized = this.normalizePrivateKey( + privateKey, + privateKeyEnvName, + ); const account = new MemoryAccount(normalized); this.accountsByKey.set(privateKey, account); return account; diff --git a/src/profile/services/profile-x-invite.service.spec.ts b/src/profile/services/profile-x-invite.service.spec.ts index de1f4bbb..8649729c 100644 --- a/src/profile/services/profile-x-invite.service.spec.ts +++ b/src/profile/services/profile-x-invite.service.spec.ts @@ -33,7 +33,9 @@ describe.skip('ProfileXInviteService', () => { createQueryBuilder: jest.fn().mockReturnValue(inviteCreditInsertBuilder), } as any; const milestoneRewardRepository = { - findOne: jest.fn().mockResolvedValue({ status: 'pending', tx_hash: null }), + findOne: jest + .fn() + .mockResolvedValue({ status: 'pending', tx_hash: null }), save: jest.fn().mockImplementation(async (v) => v), create: jest.fn().mockImplementation((v) => v), } as any; @@ -179,8 +181,12 @@ describe.skip('ProfileXInviteService', () => { }); it('creates one credit and triggers milestone reward spend once', async () => { - const { service, inviteRepository, inviteCreditInsertBuilder, aeSdkService } = - getService(); + const { + service, + inviteRepository, + inviteCreditInsertBuilder, + aeSdkService, + } = getService(); inviteRepository.findOne.mockResolvedValue({ code: 'abc123def456', inviter_address: 'ak_2A9A8vXrX3tQzN5xW1TfFjBgfDkJtN2gQq7mB7cDgY7xT2R9s', @@ -191,7 +197,9 @@ describe.skip('ProfileXInviteService', () => { await service.processInviteeXVerified( 'ak_2EZDUTjrzPUikzNereYcBHMYHXaLTn9F6SJJhw6kDEiP4F4Amo', ); - inviteCreditInsertBuilder.execute.mockResolvedValueOnce({ identifiers: [] }); + inviteCreditInsertBuilder.execute.mockResolvedValueOnce({ + identifiers: [], + }); await service.processInviteeXVerified( 'ak_2EZDUTjrzPUikzNereYcBHMYHXaLTn9F6SJJhw6kDEiP4F4Amo', ); @@ -200,8 +208,13 @@ describe.skip('ProfileXInviteService', () => { }); it('does not enqueue milestone payout when a fresh pending reward already exists', async () => { - const { service, inviteRepository, inviteCreditInsertBuilder, aeSdkService, manager } = - getService(); + const { + service, + inviteRepository, + inviteCreditInsertBuilder, + aeSdkService, + manager, + } = getService(); inviteRepository.findOne.mockResolvedValue({ code: 'abc123def456', inviter_address: 'ak_2A9A8vXrX3tQzN5xW1TfFjBgfDkJtN2gQq7mB7cDgY7xT2R9s', @@ -214,7 +227,8 @@ describe.skip('ProfileXInviteService', () => { where: jest.fn().mockReturnThis(), andWhere: jest.fn().mockReturnThis(), getOne: jest.fn().mockResolvedValue({ - inviter_address: 'ak_2A9A8vXrX3tQzN5xW1TfFjBgfDkJtN2gQq7mB7cDgY7xT2R9s', + inviter_address: + 'ak_2A9A8vXrX3tQzN5xW1TfFjBgfDkJtN2gQq7mB7cDgY7xT2R9s', threshold: 10, status: 'pending', tx_hash: null, diff --git a/src/social/config/post-contracts.config.ts b/src/social/config/post-contracts.config.ts index ccaa9213..1251e099 100644 --- a/src/social/config/post-contracts.config.ts +++ b/src/social/config/post-contracts.config.ts @@ -10,25 +10,25 @@ import { IPostContract } from '../interfaces/post.interfaces'; * Configuration for supported post contracts per network. * Each contract represents a different version or type of social posting functionality. */ -export const POST_CONTRACTS_BY_NETWORK: Record< - INetworkTypes, - IPostContract[] -> = { - [NETWORK_ID_MAINNET]: [ - { - contractAddress: 'ct_2Hyt9ZxzXra5NAzhePkRsDPDWppoatVD7CtHnUoHVbuehwR8Nb', - version: 3, - description: 'Current social posting contract (mainnet)', - }, - ], - [NETWORK_ID_TESTNET]: [ - { - contractAddress: 'ct_2J1wuuw9urs9ADBh5QbvuPyUCLdKbW5YRkfhgPoN7rGjBbPiBW', - version: 3, - description: 'Current social posting contract (testnet)', - }, - ], -}; +export const POST_CONTRACTS_BY_NETWORK: Record = + { + [NETWORK_ID_MAINNET]: [ + { + contractAddress: + 'ct_2Hyt9ZxzXra5NAzhePkRsDPDWppoatVD7CtHnUoHVbuehwR8Nb', + version: 3, + description: 'Current social posting contract (mainnet)', + }, + ], + [NETWORK_ID_TESTNET]: [ + { + contractAddress: + 'ct_2J1wuuw9urs9ADBh5QbvuPyUCLdKbW5YRkfhgPoN7rGjBbPiBW', + version: 3, + description: 'Current social posting contract (testnet)', + }, + ], + }; /** Contracts for the active network (used by PostService and helpers). */ export const POST_CONTRACTS: IPostContract[] = diff --git a/src/social/controllers/giphy.controller.ts b/src/social/controllers/giphy.controller.ts index 538e50fe..4c8881f9 100644 --- a/src/social/controllers/giphy.controller.ts +++ b/src/social/controllers/giphy.controller.ts @@ -8,7 +8,12 @@ import { Query, ServiceUnavailableException, } from '@nestjs/common'; -import { ApiOkResponse, ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger'; +import { + ApiOkResponse, + ApiOperation, + ApiQuery, + ApiTags, +} from '@nestjs/swagger'; import { CACHE_MANAGER } from '@nestjs/cache-manager'; import { Cache } from 'cache-manager'; import { fetchJson } from '@/utils/common'; @@ -62,9 +67,24 @@ export class GiphyController { constructor(@Inject(CACHE_MANAGER) private cacheManager: Cache) {} - @ApiQuery({ name: 'q', type: 'string', required: false, description: 'Search query. Omit for trending GIFs.' }) - @ApiQuery({ name: 'limit', type: 'number', required: false, description: 'Number of results (max 50)' }) - @ApiQuery({ name: 'offset', type: 'number', required: false, description: 'Pagination offset' }) + @ApiQuery({ + name: 'q', + type: 'string', + required: false, + description: 'Search query. Omit for trending GIFs.', + }) + @ApiQuery({ + name: 'limit', + type: 'number', + required: false, + description: 'Number of results (max 50)', + }) + @ApiQuery({ + name: 'offset', + type: 'number', + required: false, + description: 'Pagination offset', + }) @ApiOperation({ operationId: 'giphySearch', summary: 'Search or list trending GIFs', @@ -83,7 +103,8 @@ export class GiphyController { const endpoint = q ? 'search' : 'trending'; const cacheKey = `gif:${endpoint}:${q || ''}:${safeLimit}:${safeOffset}`; - const cached = await this.cacheManager.get(cacheKey); + const cached = + await this.cacheManager.get(cacheKey); if (cached) return cached; const apiKey = process.env.GIPHY_API_KEY; @@ -98,7 +119,9 @@ export class GiphyController { } if (!result) { - throw new ServiceUnavailableException('All GIF providers are currently unavailable'); + throw new ServiceUnavailableException( + 'All GIF providers are currently unavailable', + ); } await this.cacheManager.set(cacheKey, result, CACHE_TTL_MS); @@ -119,7 +142,9 @@ export class GiphyController { url.searchParams.set('offset', String(offset)); try { - const { data: responseData, pagination } = await fetchJson(url.toString()); + const { data: responseData, pagination } = await fetchJson( + url.toString(), + ); return { results: (responseData as any[]).map(mapGiphyGif), totalCount: pagination.total_count, @@ -127,7 +152,10 @@ export class GiphyController { hasMore: pagination.offset + pagination.count < pagination.total_count, }; } catch (error) { - this.logger.warn('Giphy API request failed, falling back to InfiniteGIF', error); + this.logger.warn( + 'Giphy API request failed, falling back to InfiniteGIF', + error, + ); return null; } } @@ -148,7 +176,9 @@ export class GiphyController { const data = await fetchJson(url.toString()); const raw: any[] = data.gifs ?? data.results ?? []; - const results = raw.map(mapInfiniteGif).filter(Boolean) as GiphyGifDto[]; + const results = raw + .map(mapInfiniteGif) + .filter(Boolean) as GiphyGifDto[]; return { results, diff --git a/src/social/controllers/posts.controller.spec.ts b/src/social/controllers/posts.controller.spec.ts new file mode 100644 index 00000000..695ee172 --- /dev/null +++ b/src/social/controllers/posts.controller.spec.ts @@ -0,0 +1,74 @@ +import { PostsController } from './posts.controller'; +import { paginate } from 'nestjs-typeorm-paginate'; + +jest.mock('nestjs-typeorm-paginate', () => ({ + paginate: jest.fn().mockResolvedValue({ items: [], meta: {} }), +})); + +describe('PostsController', () => { + let controller: PostsController; + let postRepository: { + createQueryBuilder: jest.Mock; + }; + let baseQueryBuilder: { + leftJoin: jest.Mock; + where: jest.Mock; + andWhere: jest.Mock; + select: jest.Mock; + addSelect: jest.Mock; + groupBy: jest.Mock; + orderBy: jest.Mock; + offset: jest.Mock; + limit: jest.Mock; + getRawMany: jest.Mock; + }; + let emptyResultQueryBuilder: { + where: jest.Mock; + }; + + beforeEach(() => { + baseQueryBuilder = { + leftJoin: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + select: jest.fn().mockReturnThis(), + addSelect: jest.fn().mockReturnThis(), + groupBy: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + offset: jest.fn().mockReturnThis(), + limit: jest.fn().mockReturnThis(), + getRawMany: jest.fn().mockResolvedValue([]), + }; + emptyResultQueryBuilder = { + where: jest.fn().mockReturnThis(), + }; + + postRepository = { + createQueryBuilder: jest + .fn() + .mockReturnValueOnce(baseQueryBuilder) + .mockReturnValueOnce(emptyResultQueryBuilder), + }; + + controller = new PostsController( + postRepository as any, + {} as any, + {} as any, + {} as any, + {} as any, + ); + }); + + it('applies search to post content and topic names', async () => { + await controller.listAll(1, 100, 'created_at', 'DESC', 'governance'); + + expect(baseQueryBuilder.andWhere).toHaveBeenCalledWith( + '(post.content ILIKE :searchTerm OR topic.name ILIKE :searchTerm)', + { searchTerm: '%governance%' }, + ); + expect(paginate).toHaveBeenCalledWith(emptyResultQueryBuilder, { + page: 1, + limit: 100, + }); + }); +}); diff --git a/src/social/controllers/topics.controller.spec.ts b/src/social/controllers/topics.controller.spec.ts new file mode 100644 index 00000000..bb8d7671 --- /dev/null +++ b/src/social/controllers/topics.controller.spec.ts @@ -0,0 +1,40 @@ +import { TopicsController } from './topics.controller'; +import { paginate } from 'nestjs-typeorm-paginate'; + +jest.mock('nestjs-typeorm-paginate', () => ({ + paginate: jest.fn().mockResolvedValue({ items: [], meta: {} }), +})); + +describe('TopicsController', () => { + let controller: TopicsController; + let topicRepository: { + createQueryBuilder: jest.Mock; + }; + let queryBuilder: { + where: jest.Mock; + orderBy: jest.Mock; + }; + + beforeEach(() => { + queryBuilder = { + where: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + }; + + topicRepository = { + createQueryBuilder: jest.fn(() => queryBuilder), + }; + + controller = new TopicsController(topicRepository as any); + }); + + it('applies search to topic names', async () => { + await controller.listAll(1, 50, 'post_count', 'DESC', 'governance'); + + expect(queryBuilder.where).toHaveBeenCalledWith( + 'topic.name ILIKE :searchTerm', + { searchTerm: '%governance%' }, + ); + expect(paginate).toHaveBeenCalledWith(queryBuilder, { page: 1, limit: 50 }); + }); +}); diff --git a/src/social/dto/giphy.dto.ts b/src/social/dto/giphy.dto.ts index bb149499..330ee8cf 100644 --- a/src/social/dto/giphy.dto.ts +++ b/src/social/dto/giphy.dto.ts @@ -4,16 +4,28 @@ export class GiphyGifDto { @ApiProperty({ example: 'xT4uQulxzV39haRFjG' }) id: string; - @ApiProperty({ example: 'https://media.giphy.com/media/xT4u/200w_s.gif', nullable: true }) + @ApiProperty({ + example: 'https://media.giphy.com/media/xT4u/200w_s.gif', + nullable: true, + }) still: string | null; - @ApiProperty({ example: 'https://media.giphy.com/media/xT4u/200w.gif', nullable: true }) + @ApiProperty({ + example: 'https://media.giphy.com/media/xT4u/200w.gif', + nullable: true, + }) animated: string | null; - @ApiProperty({ example: 'https://media.giphy.com/media/xT4u/200w.mp4', nullable: true }) + @ApiProperty({ + example: 'https://media.giphy.com/media/xT4u/200w.mp4', + nullable: true, + }) mp4: string | null; - @ApiProperty({ example: 'https://media.giphy.com/media/xT4u/giphy.gif', nullable: true }) + @ApiProperty({ + example: 'https://media.giphy.com/media/xT4u/giphy.gif', + nullable: true, + }) original: string | null; @ApiProperty({ example: 480, description: 'Original width in pixels' }) diff --git a/src/social/services/popular-ranking.service.spec.ts b/src/social/services/popular-ranking.service.spec.ts index f2824d4e..493efc53 100644 --- a/src/social/services/popular-ranking.service.spec.ts +++ b/src/social/services/popular-ranking.service.spec.ts @@ -113,7 +113,8 @@ describe('PopularRankingService', () => { it('excludes self-tips from popular ranking tip aggregates', async () => { await service.recompute('24h', 10); - const tipQueryBuilder = tipRepository.createQueryBuilder.mock.results[0].value; + const tipQueryBuilder = + tipRepository.createQueryBuilder.mock.results[0].value; expect(tipQueryBuilder.innerJoin).toHaveBeenCalledWith( expect.any(Function), diff --git a/src/social/services/post.service.spec.ts b/src/social/services/post.service.spec.ts index 31d5ec20..b785425d 100644 --- a/src/social/services/post.service.spec.ts +++ b/src/social/services/post.service.spec.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/no-var-requires */ import { Test, TestingModule } from '@nestjs/testing'; import { getRepositoryToken } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; diff --git a/src/social/services/post.service.ts b/src/social/services/post.service.ts index 1c26e3a2..2963d494 100644 --- a/src/social/services/post.service.ts +++ b/src/social/services/post.service.ts @@ -390,15 +390,15 @@ export class PostService { ...existingPost, post_id: postTypeInfo.parentPostId, } as Post, - loadParentPost: (postId) => - this.postRepository.findOne({ - where: { id: postId }, - }), - updateTrendingScoresForSymbols: (symbols) => - this.tokensService.updateTrendingScoresForSymbols(symbols), - logError: (message, trace) => this.logger.error(message, trace), - errorMessage: 'Failed to refresh trending scores after saving post', - }); + loadParentPost: (postId) => + this.postRepository.findOne({ + where: { id: postId }, + }), + updateTrendingScoresForSymbols: (symbols) => + this.tokensService.updateTrendingScoresForSymbols(symbols), + logError: (message, trace) => this.logger.error(message, trace), + errorMessage: 'Failed to refresh trending scores after saving post', + }); return existingPost; } else { this.logger.warn('Failed to process existing post as comment', { diff --git a/src/social/utils/content-parser.util.ts b/src/social/utils/content-parser.util.ts index 023f9575..adaa5e79 100644 --- a/src/social/utils/content-parser.util.ts +++ b/src/social/utils/content-parser.util.ts @@ -30,7 +30,10 @@ export function parsePostContent( // Extract topics (hashtags) const topics = extractTopics(sanitizedContent, config.maxTopics); - const trendMentions = extractTrendMentions(sanitizedContent, config.maxTopics); + const trendMentions = extractTrendMentions( + sanitizedContent, + config.maxTopics, + ); // Extract media URLs const media = extractMedia(mediaArguments, config.maxMediaItems); diff --git a/src/social/utils/token-mentions-sql.util.ts b/src/social/utils/token-mentions-sql.util.ts index 845e453c..0487c816 100644 --- a/src/social/utils/token-mentions-sql.util.ts +++ b/src/social/utils/token-mentions-sql.util.ts @@ -1,7 +1,8 @@ -export const TOKEN_HASHTAG_REGEX_SOURCE = - '#([A-Za-z0-9_][A-Za-z0-9_-]{0,49})'; +export const TOKEN_HASHTAG_REGEX_SOURCE = '#([A-Za-z0-9_][A-Za-z0-9_-]{0,49})'; -export function buildNormalizedTokenMentionSelectSql(postAlias: string): string { +export function buildNormalizedTokenMentionSelectSql( + postAlias: string, +): string { return ` SELECT DISTINCT UPPER(mention.symbol) AS symbol FROM ( diff --git a/src/social/utils/token-mentions.util.spec.ts b/src/social/utils/token-mentions.util.spec.ts index cba19dee..3c166870 100644 --- a/src/social/utils/token-mentions.util.spec.ts +++ b/src/social/utils/token-mentions.util.spec.ts @@ -32,7 +32,9 @@ describe('resolveTrendingSymbolsForPost', () => { const loadParentPost = jest.fn().mockResolvedValue({ token_mentions: ['BETA'], }); - const updateTrendingScoresForSymbols = jest.fn().mockResolvedValue(undefined); + const updateTrendingScoresForSymbols = jest + .fn() + .mockResolvedValue(undefined); const logError = jest.fn(); await refreshTrendingScoresForPostSafely({ diff --git a/src/tipping/controllers/tips.controller.spec.ts b/src/tipping/controllers/tips.controller.spec.ts index fa3e898a..9256f30c 100644 --- a/src/tipping/controllers/tips.controller.spec.ts +++ b/src/tipping/controllers/tips.controller.spec.ts @@ -23,7 +23,10 @@ describe('TipsController', () => { findOne: jest.fn(), }; - controller = new TipsController(tipRepository as any, postRepository as any); + controller = new TipsController( + tipRepository as any, + postRepository as any, + ); }); it('excludes self-tips from account summary totals', async () => { diff --git a/src/tipping/services/tips.service.spec.ts b/src/tipping/services/tips.service.spec.ts index 18ab1e9b..9edbffdc 100644 --- a/src/tipping/services/tips.service.spec.ts +++ b/src/tipping/services/tips.service.spec.ts @@ -70,7 +70,10 @@ describe('TipService', () => { sender_address: 'ak_sender', token_mentions: ['ALPHA'], }); - const ensureAccountExists = jest.spyOn(service as any, 'ensureAccountExists'); + const ensureAccountExists = jest.spyOn( + service as any, + 'ensureAccountExists', + ); const result = await service.saveTipFromTransaction( { @@ -92,7 +95,10 @@ describe('TipService', () => { it('does not persist self-tips on a profile', async () => { tipRepository.findOne.mockResolvedValue(null); - const ensureAccountExists = jest.spyOn(service as any, 'ensureAccountExists'); + const ensureAccountExists = jest.spyOn( + service as any, + 'ensureAccountExists', + ); const result = await service.saveTipFromTransaction( { @@ -114,23 +120,23 @@ describe('TipService', () => { }); it('marks self-tips as skipped during live handling', async () => { - jest.spyOn(service as any, 'validateTransaction').mockReturnValue('TIP_POST:post-1'); + jest + .spyOn(service as any, 'validateTransaction') + .mockReturnValue('TIP_POST:post-1'); postRepository.findOne.mockResolvedValue({ id: 'post-1', sender_address: 'ak_sender', token_mentions: ['ALPHA'], }); - const result = await service.handleLiveTransaction( - { - hash: 'th_self_tip', - tx: { - senderId: 'ak_sender', - recipientId: 'ak_sender', - amount: '1000000000000000000', - }, - } as any, - ); + const result = await service.handleLiveTransaction({ + hash: 'th_self_tip', + tx: { + senderId: 'ak_sender', + recipientId: 'ak_sender', + amount: '1000000000000000000', + }, + } as any); expect(result).toEqual({ success: false, diff --git a/src/tipping/utils/is-self-tip.util.spec.ts b/src/tipping/utils/is-self-tip.util.spec.ts index 92aa5be9..539bdb03 100644 --- a/src/tipping/utils/is-self-tip.util.spec.ts +++ b/src/tipping/utils/is-self-tip.util.spec.ts @@ -2,15 +2,15 @@ import { isSelfTip } from './is-self-tip.util'; describe('isSelfTip', () => { it('returns true when post tip sender is the post author', () => { - expect( - isSelfTip('ak_1', 'ak_2', { sender_address: 'ak_1' } as any), - ).toBe(true); + expect(isSelfTip('ak_1', 'ak_2', { sender_address: 'ak_1' } as any)).toBe( + true, + ); }); it('returns false when post tip sender is not the post author', () => { - expect( - isSelfTip('ak_1', 'ak_2', { sender_address: 'ak_3' } as any), - ).toBe(false); + expect(isSelfTip('ak_1', 'ak_2', { sender_address: 'ak_3' } as any)).toBe( + false, + ); }); it('returns true for profile self-tip when sender equals receiver', () => { diff --git a/src/tokens/account-tokens.controller.spec.ts b/src/tokens/account-tokens.controller.spec.ts index 82b7fa5f..a6b71fb9 100644 --- a/src/tokens/account-tokens.controller.spec.ts +++ b/src/tokens/account-tokens.controller.spec.ts @@ -19,6 +19,12 @@ describe('AccountTokensController', () => { let tokenHolderRepository: Repository; let communityFactoryService: CommunityFactoryService; let tokensService: jest.Mocked; + let tokenHolderQueryBuilder: { + orderBy: jest.Mock; + where: jest.Mock; + andWhere: jest.Mock; + leftJoinAndSelect: jest.Mock; + }; beforeEach(async () => { tokensService = { @@ -26,6 +32,13 @@ describe('AccountTokensController', () => { getTokensByAex9Address: jest.fn().mockResolvedValue([]), } as any; + tokenHolderQueryBuilder = { + orderBy: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + leftJoinAndSelect: jest.fn().mockReturnThis(), + }; + const module: TestingModule = await Test.createTestingModule({ controllers: [AccountTokensController], providers: [ @@ -93,12 +106,9 @@ describe('AccountTokensController', () => { }; (paginate as jest.Mock).mockResolvedValue(mockPagination); - jest.spyOn(tokenHolderRepository, 'createQueryBuilder').mockReturnValue({ - orderBy: jest.fn().mockReturnThis(), - where: jest.fn().mockReturnThis(), - andWhere: jest.fn().mockReturnThis(), - leftJoinAndSelect: jest.fn().mockReturnThis(), - } as any); + jest + .spyOn(tokenHolderRepository, 'createQueryBuilder') + .mockReturnValue(tokenHolderQueryBuilder as any); tokensService.getTokenRanksByAex9Address.mockResolvedValue( new Map([['ct_token_1', 7]]), ); @@ -124,6 +134,33 @@ describe('AccountTokensController', () => { expect(paginate).toHaveBeenCalled(); }); + it('should apply search to token names', async () => { + (paginate as jest.Mock).mockResolvedValue({ + items: [], + meta: { + totalItems: 0, + itemCount: 0, + itemsPerPage: 10, + totalPages: 0, + currentPage: 1, + }, + }); + jest + .spyOn(tokenHolderRepository, 'createQueryBuilder') + .mockReturnValue(tokenHolderQueryBuilder as any); + + await controller.listAccountTokens('test_address', 'alice'); + + expect(tokenHolderQueryBuilder.andWhere).toHaveBeenCalledWith( + 'token.name ILIKE :search', + { search: '%alice%' }, + ); + expect(paginate).toHaveBeenCalledWith(tokenHolderQueryBuilder, { + page: 1, + limit: 100, + }); + }); + it('should use the factory address if factory_address is not provided', async () => { jest.spyOn(tokenHolderRepository, 'createQueryBuilder').mockReturnValue({ orderBy: jest.fn().mockReturnThis(), diff --git a/src/tokens/queues/sync-token-holders.queue.spec.ts b/src/tokens/queues/sync-token-holders.queue.spec.ts index a4762836..8439a78a 100644 --- a/src/tokens/queues/sync-token-holders.queue.spec.ts +++ b/src/tokens/queues/sync-token-holders.queue.spec.ts @@ -148,9 +148,9 @@ describe('SyncTokenHoldersQueue', () => { new Error('sync failed'), ); - await expect(queue.process({ data: { saleAddress } } as any)).rejects.toThrow( - 'sync failed', - ); + await expect( + queue.process({ data: { saleAddress } } as any), + ).rejects.toThrow('sync failed'); expect(tokenHoldersLockService.releaseLock).toHaveBeenCalledWith( saleAddress, diff --git a/src/tokens/queues/sync-token-holders.queue.ts b/src/tokens/queues/sync-token-holders.queue.ts index 94d63e7d..8937830d 100644 --- a/src/tokens/queues/sync-token-holders.queue.ts +++ b/src/tokens/queues/sync-token-holders.queue.ts @@ -3,7 +3,10 @@ import { Encoded } from '@aeternity/aepp-sdk'; import { Process, Processor } from '@nestjs/bull'; import { Logger } from '@nestjs/common'; import { Job } from 'bull'; -import { RetryableTokenHoldersSyncError, TokensService } from '../tokens.service'; +import { + RetryableTokenHoldersSyncError, + TokensService, +} from '../tokens.service'; import { TokenHoldersLockService } from '../services/token-holders-lock.service'; import { SYNC_TOKEN_HOLDERS_QUEUE } from './constants'; diff --git a/src/tokens/services/refresh-token-eligibility-counts.service.spec.ts b/src/tokens/services/refresh-token-eligibility-counts.service.spec.ts index 990618f8..f5580d4c 100644 --- a/src/tokens/services/refresh-token-eligibility-counts.service.spec.ts +++ b/src/tokens/services/refresh-token-eligibility-counts.service.spec.ts @@ -46,7 +46,9 @@ describe('RefreshTokenEligibilityCountsService', () => { expect(dataSource.query).toHaveBeenNthCalledWith( 1, - expect.stringContaining('CREATE TABLE IF NOT EXISTS token_eligibility_counts'), + expect.stringContaining( + 'CREATE TABLE IF NOT EXISTS token_eligibility_counts', + ), ); expect(dataSource.query).toHaveBeenNthCalledWith( 2, @@ -233,7 +235,9 @@ describe('RefreshTokenEligibilityCountsService', () => { expect(dataSource.query).toHaveBeenNthCalledWith( 1, - expect.stringContaining('CREATE TABLE IF NOT EXISTS token_eligibility_counts'), + expect.stringContaining( + 'CREATE TABLE IF NOT EXISTS token_eligibility_counts', + ), ); expect(dataSource.query).toHaveBeenNthCalledWith( 2, diff --git a/src/tokens/services/update-trending-tokens.service.spec.ts b/src/tokens/services/update-trending-tokens.service.spec.ts index dac3fe6d..617c3edf 100644 --- a/src/tokens/services/update-trending-tokens.service.spec.ts +++ b/src/tokens/services/update-trending-tokens.service.spec.ts @@ -16,7 +16,9 @@ describe('UpdateTrendingTokensService', () => { createQueryBuilder: jest.fn(), }; tokensService = { - updateMultipleTokensTrendingScores: jest.fn().mockResolvedValue(undefined), + updateMultipleTokensTrendingScores: jest + .fn() + .mockResolvedValue(undefined), }; service = new UpdateTrendingTokensService( @@ -31,9 +33,9 @@ describe('UpdateTrendingTokensService', () => { }); it('refreshes active tokens by oldest score update first instead of market cap', async () => { - const getRawManyTransactions = jest.fn().mockResolvedValue([ - { sale_address: 'ct_trade' }, - ]); + const getRawManyTransactions = jest + .fn() + .mockResolvedValue([{ sale_address: 'ct_trade' }]); transactionsRepository.createQueryBuilder.mockReturnValue({ select: jest.fn().mockReturnThis(), where: jest.fn().mockReturnThis(), @@ -74,9 +76,9 @@ describe('UpdateTrendingTokensService', () => { expect(activeTokenQb.limit).toHaveBeenCalledWith( TRENDING_SCORE_CONFIG.MAX_ACTIVE_BATCH, ); - expect(tokensService.updateMultipleTokensTrendingScores).toHaveBeenCalledWith([ - { sale_address: 'ct_trade' }, - ]); + expect( + tokensService.updateMultipleTokensTrendingScores, + ).toHaveBeenCalledWith([{ sale_address: 'ct_trade' }]); }); it('uses a lookback window that covers the full active refresh cadence', async () => { @@ -124,7 +126,9 @@ describe('UpdateTrendingTokensService', () => { andWhere: jest.fn().mockReturnThis(), orderBy: jest.fn().mockReturnThis(), limit: jest.fn().mockReturnThis(), - getMany: jest.fn().mockResolvedValue([{ sale_address: 'ct_never_scored' }]), + getMany: jest + .fn() + .mockResolvedValue([{ sale_address: 'ct_never_scored' }]), }; tokensRepository.createQueryBuilder.mockReturnValue(staleQb); @@ -139,9 +143,9 @@ describe('UpdateTrendingTokensService', () => { expect(staleQb.limit).toHaveBeenCalledWith( TRENDING_SCORE_CONFIG.MAX_STALE_BATCH, ); - expect(tokensService.updateMultipleTokensTrendingScores).toHaveBeenCalledWith([ - { sale_address: 'ct_never_scored' }, - ]); + expect( + tokensService.updateMultipleTokensTrendingScores, + ).toHaveBeenCalledWith([{ sale_address: 'ct_never_scored' }]); }); it('skips stale backfill when another trending refresh is already running', async () => { @@ -150,7 +154,9 @@ describe('UpdateTrendingTokensService', () => { await service.fixOldTrendingTokens(); expect(tokensRepository.createQueryBuilder).not.toHaveBeenCalled(); - expect(tokensService.updateMultipleTokensTrendingScores).not.toHaveBeenCalled(); + expect( + tokensService.updateMultipleTokensTrendingScores, + ).not.toHaveBeenCalled(); }); it('ignores self-tips when collecting recently tipped symbols', async () => { diff --git a/src/tokens/tokens.controller.spec.ts b/src/tokens/tokens.controller.spec.ts index 2a57673e..d2c3ba70 100644 --- a/src/tokens/tokens.controller.spec.ts +++ b/src/tokens/tokens.controller.spec.ts @@ -37,19 +37,30 @@ describe('TokensController', () => { getCount: jest.Mock; getRawMany: jest.Mock; }; + let tokensQueryBuilder: { + select: jest.Mock; + orderBy: jest.Mock; + where: jest.Mock; + andWhere: jest.Mock; + andWhereInIds: jest.Mock; + getCount: jest.Mock; + getMany: jest.Mock; + }; beforeEach(async () => { + tokensQueryBuilder = { + select: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + andWhereInIds: jest.fn().mockReturnThis(), + getCount: jest.fn().mockResolvedValue(2), + getMany: jest.fn().mockResolvedValue([]), + }; + const tokensRepositoryMock = { query: jest.fn().mockResolvedValue([]), - createQueryBuilder: jest.fn(() => ({ - select: jest.fn().mockReturnThis(), - orderBy: jest.fn().mockReturnThis(), - where: jest.fn().mockReturnThis(), - andWhere: jest.fn().mockReturnThis(), - andWhereInIds: jest.fn().mockReturnThis(), - getCount: jest.fn().mockResolvedValue(2), - getMany: jest.fn().mockResolvedValue([]), - })), + createQueryBuilder: jest.fn(() => tokensQueryBuilder), }; tokenHolderQueryBuilder = { @@ -176,6 +187,22 @@ describe('TokensController', () => { expect(result).toEqual({ items: [], meta: {} }); }); + it('should apply search to token names', async () => { + await controller.listAll('alice'); + + expect(tokensQueryBuilder.andWhere).toHaveBeenCalledWith( + 'token.name ILIKE :search', + { search: '%alice%' }, + ); + expect(tokensService.queryTokensWithRanks).toHaveBeenCalledWith( + tokensQueryBuilder, + 100, + 1, + 'market_cap', + 'DESC', + ); + }); + it('should apply eligibility filters only for trending score ordering', async () => { await controller.listAll( undefined, diff --git a/src/tokens/tokens.service.spec.ts b/src/tokens/tokens.service.spec.ts index 20c47730..10ad1bea 100644 --- a/src/tokens/tokens.service.spec.ts +++ b/src/tokens/tokens.service.spec.ts @@ -3,7 +3,10 @@ import { TOKEN_LIST_ELIGIBILITY_CONFIG, TRENDING_SCORE_CONFIG, } from '@/configs/constants'; -import { RetryableTokenHoldersSyncError, TokensService } from './tokens.service'; +import { + RetryableTokenHoldersSyncError, + TokensService, +} from './tokens.service'; describe('TokensService', () => { let service: TokensService; @@ -156,23 +159,27 @@ describe('TokensService', () => { expect(socialQuery).toContain( 'INNER JOIN posts tipped_post ON tipped_post.id = tip.post_id', ); - expect((socialQuery.match(/tip\.sender_address != tipped_post\.sender_address/g) || []).length).toBe(3); + expect( + ( + socialQuery.match( + /tip\.sender_address != tipped_post\.sender_address/g, + ) || [] + ).length, + ).toBe(3); }); it('throws a not found error when updating a missing token', async () => { - await expect(service.updateTokenTrendingScore(null as any)).rejects.toBeInstanceOf( - NotFoundException, - ); + await expect( + service.updateTokenTrendingScore(null as any), + ).rejects.toBeInstanceOf(NotFoundException); }); it('persists zero when the calculated score is non-finite', async () => { - jest - .spyOn(service, 'calculateTokenTrendingMetrics') - .mockResolvedValue({ - trending_score: { - result: Number.NaN, - }, - } as any); + jest.spyOn(service, 'calculateTokenTrendingMetrics').mockResolvedValue({ + trending_score: { + result: Number.NaN, + }, + } as any); const result = await service.updateTokenTrendingScore({ sale_address: 'ct_sale', @@ -198,11 +205,19 @@ describe('TokensService', () => { .spyOn(service, 'updateMultipleTokensTrendingScores') .mockResolvedValue(undefined); - await service.updateTrendingScoresForSymbols([' test ', 'TEST', '', 'alpha']); + await service.updateTrendingScoresForSymbols([ + ' test ', + 'TEST', + '', + 'alpha', + ]); - expect(andWhere).toHaveBeenCalledWith('UPPER(token.symbol) IN (:...symbols)', { - symbols: ['TEST', 'ALPHA'], - }); + expect(andWhere).toHaveBeenCalledWith( + 'UPPER(token.symbol) IN (:...symbols)', + { + symbols: ['TEST', 'ALPHA'], + }, + ); expect(service.updateMultipleTokensTrendingScores).toHaveBeenCalledWith([ { sale_address: 'ct_sale' }, ]); @@ -212,16 +227,18 @@ describe('TokensService', () => { let active = 0; let maxActive = 0; - jest.spyOn(service, 'updateTokenTrendingScore').mockImplementation(async () => { - active += 1; - maxActive = Math.max(maxActive, active); - await new Promise((resolve) => setTimeout(resolve, 5)); - active -= 1; - return { - metrics: {} as any, - token: {} as any, - }; - }); + jest + .spyOn(service, 'updateTokenTrendingScore') + .mockImplementation(async () => { + active += 1; + maxActive = Math.max(maxActive, active); + await new Promise((resolve) => setTimeout(resolve, 5)); + active -= 1; + return { + metrics: {} as any, + token: {} as any, + }; + }); await service.updateMultipleTokensTrendingScores( Array.from({ length: 20 }, (_, index) => ({ @@ -274,8 +291,7 @@ describe('TokensService', () => { eligibilityMinHolders: TOKEN_LIST_ELIGIBILITY_CONFIG.MIN_HOLDERS, eligibilityMinPosts: TOKEN_LIST_ELIGIBILITY_CONFIG.MIN_TOKEN_POSTS_ALL_TIME, - eligibilityMinTrades: - TOKEN_LIST_ELIGIBILITY_CONFIG.MIN_TRADES_ALL_TIME, + eligibilityMinTrades: TOKEN_LIST_ELIGIBILITY_CONFIG.MIN_TRADES_ALL_TIME, }), ); }); @@ -299,7 +315,8 @@ describe('TokensService', () => { const breakdown = await service.getTrendingEligibilityBreakdown('ct_sale'); - const [eligibilityQuery, eligibilityParams] = tokensRepository.query.mock.calls[0]; + const [eligibilityQuery, eligibilityParams] = + tokensRepository.query.mock.calls[0]; expect(tokenEligibilityCountsRepository.findOne).toHaveBeenCalledWith({ where: { symbol: 'TEST' }, }); diff --git a/src/tokens/tokens.service.ts b/src/tokens/tokens.service.ts index b4a4c927..578ce9b2 100644 --- a/src/tokens/tokens.service.ts +++ b/src/tokens/tokens.service.ts @@ -750,8 +750,7 @@ export class TokensService { eligibilityMinHolders: TOKEN_LIST_ELIGIBILITY_CONFIG.MIN_HOLDERS, eligibilityMinPosts: TOKEN_LIST_ELIGIBILITY_CONFIG.MIN_TOKEN_POSTS_ALL_TIME, - eligibilityMinTrades: - TOKEN_LIST_ELIGIBILITY_CONFIG.MIN_TRADES_ALL_TIME, + eligibilityMinTrades: TOKEN_LIST_ELIGIBILITY_CONFIG.MIN_TRADES_ALL_TIME, }, ); } @@ -789,7 +788,8 @@ export class TokensService { const passes = { holders: holdersCount >= TOKEN_LIST_ELIGIBILITY_CONFIG.MIN_HOLDERS, - posts: postCount >= TOKEN_LIST_ELIGIBILITY_CONFIG.MIN_TOKEN_POSTS_ALL_TIME, + posts: + postCount >= TOKEN_LIST_ELIGIBILITY_CONFIG.MIN_TOKEN_POSTS_ALL_TIME, trades: tradeCount >= TOKEN_LIST_ELIGIBILITY_CONFIG.MIN_TRADES_ALL_TIME, eligible: false, }; @@ -1299,8 +1299,11 @@ export class TokensService { } private isContractNotPresentError(error: any): boolean { - const message = `${error?.message || ''} ${error?.reason || ''}`.toLowerCase(); - return message.includes('contract not found') || message.includes('not_present'); + const message = + `${error?.message || ''} ${error?.reason || ''}`.toLowerCase(); + return ( + message.includes('contract not found') || message.includes('not_present') + ); } private sleep(ms: number): Promise { @@ -1571,12 +1574,12 @@ export class TokensService { const lastSocialActivityAt = social.last_social_activity_at ? new Date(social.last_social_activity_at) : null; - const lastActivityAt = this.getLatestDate(lastTradeAt, lastSocialActivityAt); + const lastActivityAt = this.getLatestDate( + lastTradeAt, + lastSocialActivityAt, + ); const ageHoursSinceLastActivity = lastActivityAt - ? Math.max( - 0, - (Date.now() - lastActivityAt.getTime()) / (1000 * 60 * 60), - ) + ? Math.max(0, (Date.now() - lastActivityAt.getTime()) / (1000 * 60 * 60)) : TRENDING_SCORE_CONFIG.WINDOW_HOURS; const decayMultiplier = @@ -1584,8 +1587,7 @@ export class TokensService { ? 1 / Math.pow( 1 + - ageHoursSinceLastActivity / - TRENDING_SCORE_CONFIG.DECAY.biasHours, + ageHoursSinceLastActivity / TRENDING_SCORE_CONFIG.DECAY.biasHours, TRENDING_SCORE_CONFIG.DECAY.gravity, ) : 0; diff --git a/src/transactions/controllers/historical.controller.ts b/src/transactions/controllers/historical.controller.ts index 6664a407..60b7cb52 100644 --- a/src/transactions/controllers/historical.controller.ts +++ b/src/transactions/controllers/historical.controller.ts @@ -236,7 +236,9 @@ export class HistoricalController { }); const d = points - .map(([x, y], i) => `${i === 0 ? 'M' : 'L'} ${x.toFixed(2)} ${y.toFixed(2)}`) + .map( + ([x, y], i) => `${i === 0 ? 'M' : 'L'} ${x.toFixed(2)} ${y.toFixed(2)}`, + ) .join(' '); const bg = diff --git a/src/transactions/controllers/transactions.controller.spec.ts b/src/transactions/controllers/transactions.controller.spec.ts index 693adaa0..82de8452 100644 --- a/src/transactions/controllers/transactions.controller.spec.ts +++ b/src/transactions/controllers/transactions.controller.spec.ts @@ -35,13 +35,11 @@ describe('TransactionsController', () => { { provide: TokensService, useValue: { - getToken: jest - .fn() - .mockResolvedValue({ - id: 1, - address: 'test_token', - sale_address: 'test_sale_address', - }), + getToken: jest.fn().mockResolvedValue({ + id: 1, + address: 'test_token', + sale_address: 'test_sale_address', + }), }, }, { diff --git a/src/transactions/services/transaction-history.service.ts b/src/transactions/services/transaction-history.service.ts index 4878dfef..c996ddc9 100644 --- a/src/transactions/services/transaction-history.service.ts +++ b/src/transactions/services/transaction-history.service.ts @@ -411,7 +411,7 @@ export class TransactionHistoryService { }, }; - const { interval, unit, size, timeframe, intervalMs } = types[intervalType]; + const { interval, unit, size, timeframe } = types[intervalType]; const bucketExpr = size > 1 diff --git a/src/transactions/services/transaction.service.spec.ts b/src/transactions/services/transaction.service.spec.ts index 49245d2d..64ca4c75 100644 --- a/src/transactions/services/transaction.service.spec.ts +++ b/src/transactions/services/transaction.service.spec.ts @@ -67,12 +67,10 @@ describe('TransactionService', () => { loadFactory: jest .fn() .mockResolvedValue({ contract: { $decodeEvents: jest.fn() } }), - getCurrentFactory: jest - .fn() - .mockResolvedValue({ - address: 'test_factory', - collections: { default: {} }, - }), + getCurrentFactory: jest.fn().mockResolvedValue({ + address: 'test_factory', + collections: { default: {} }, + }), } as any; tokenWebsocketGateway = { @@ -121,8 +119,11 @@ describe('TransactionService', () => { where: jest.fn().mockReturnThis(), getOne: jest.fn().mockResolvedValue(null), }; - transactionRepository.createQueryBuilder.mockImplementation((alias: string) => - alias === 'transactions' ? deleteOldCreateCommunityQuery : existingTxQuery, + transactionRepository.createQueryBuilder.mockImplementation( + (alias: string) => + alias === 'transactions' + ? deleteOldCreateCommunityQuery + : existingTxQuery, ); tokenService.getToken = jest.fn().mockResolvedValue({ sale_address: 'ct_123', diff --git a/src/trending-tags/controllers/trending-tags.controller.spec.ts b/src/trending-tags/controllers/trending-tags.controller.spec.ts new file mode 100644 index 00000000..33f30a63 --- /dev/null +++ b/src/trending-tags/controllers/trending-tags.controller.spec.ts @@ -0,0 +1,48 @@ +import { TrendingTagsController } from './trending-tags.controller'; +import { paginate } from 'nestjs-typeorm-paginate'; + +jest.mock('nestjs-typeorm-paginate', () => ({ + paginate: jest.fn().mockResolvedValue({ items: [], meta: {} }), +})); + +describe('TrendingTagsController', () => { + let controller: TrendingTagsController; + let trendingTagRepository: { + createQueryBuilder: jest.Mock; + }; + let queryBuilder: { + orderBy: jest.Mock; + where: jest.Mock; + leftJoinAndMapOne: jest.Mock; + }; + + beforeEach(() => { + queryBuilder = { + orderBy: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + leftJoinAndMapOne: jest.fn().mockReturnThis(), + }; + + trendingTagRepository = { + createQueryBuilder: jest.fn(() => queryBuilder), + }; + + controller = new TrendingTagsController( + trendingTagRepository as any, + {} as any, + ); + }); + + it('applies search to trending tag names', async () => { + await controller.listAll(1, 100, 'score', 'DESC', 'hero'); + + expect(queryBuilder.where).toHaveBeenCalledWith( + 'trending_tag.tag ILIKE :search', + { search: '%hero%' }, + ); + expect(paginate).toHaveBeenCalledWith(queryBuilder, { + page: 1, + limit: 100, + }); + }); +}); diff --git a/src/utils/database-issue-logging.spec.ts b/src/utils/database-issue-logging.spec.ts index 680629e7..141a2fc6 100644 --- a/src/utils/database-issue-logging.spec.ts +++ b/src/utils/database-issue-logging.spec.ts @@ -21,7 +21,9 @@ describe('database issue logging helpers', () => { it('classifies common database connectivity issue kinds', () => { expect( - getDatabaseIssueKind(new Error('timeout exceeded when trying to connect')), + getDatabaseIssueKind( + new Error('timeout exceeded when trying to connect'), + ), ).toBe('pool_timeout'); expect(getDatabaseIssueKind(new Error('too many clients already'))).toBe( 'pool_exhausted', diff --git a/src/utils/database-issue-logging.ts b/src/utils/database-issue-logging.ts index 4b0d6c4c..db7b8fe0 100644 --- a/src/utils/database-issue-logging.ts +++ b/src/utils/database-issue-logging.ts @@ -15,7 +15,9 @@ const defaultExtra = (DATABASE_CONFIG as any)?.extra ?? {}; export const DEFAULT_DATABASE_ISSUE_POOL_CONFIG: DatabaseIssuePoolConfig = { max: Number(defaultExtra.max ?? 40), min: Number(defaultExtra.min ?? 5), - connectionTimeoutMillis: Number(defaultExtra.connectionTimeoutMillis ?? 10_000), + connectionTimeoutMillis: Number( + defaultExtra.connectionTimeoutMillis ?? 10_000, + ), }; export function isDatabaseConnectionOrPoolError(error: unknown): boolean { diff --git a/src/utils/getBlockHeight.spec.ts b/src/utils/getBlockHeight.spec.ts index caac9def..7e2b9104 100644 --- a/src/utils/getBlockHeight.spec.ts +++ b/src/utils/getBlockHeight.spec.ts @@ -47,7 +47,10 @@ describe('batchTimestampToAeHeight', () => { { target_ms: String(ts2), height: 200 }, ]); - const result = await batchTimestampToAeHeight([ts1, ts2], dataSource as any); + const result = await batchTimestampToAeHeight( + [ts1, ts2], + dataSource as any, + ); // Only one SQL round-trip when key_blocks resolves everything expect(dataSource.query).toHaveBeenCalledTimes(1); @@ -76,7 +79,10 @@ describe('batchTimestampToAeHeight', () => { [{ target_ms: String(ts2), block_height: 205 }], ); - const result = await batchTimestampToAeHeight([ts1, ts2], dataSource as any); + const result = await batchTimestampToAeHeight( + [ts1, ts2], + dataSource as any, + ); // Two DB round-trips: key_blocks + transactions (no HTTP/guessing calls) expect(dataSource.query).toHaveBeenCalledTimes(2); @@ -115,7 +121,10 @@ describe('batchTimestampToAeHeight', () => { timestamps.map((ts, i) => ({ target_ms: String(ts), height: i + 1 })), ); - const result = await batchTimestampToAeHeight(timestamps, dataSource as any); + const result = await batchTimestampToAeHeight( + timestamps, + dataSource as any, + ); const [, params] = dataSource.query.mock.calls[0]; expect(params[0]).toEqual(timestamps);