Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions .github/workflows/branch-ci.yaml
Original file line number Diff line number Diff line change
@@ -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
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
105 changes: 105 additions & 0 deletions src/account/controllers/accounts.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -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<typeof createQueryBuilder>;
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,
);
});
});
42 changes: 41 additions & 1 deletion src/account/controllers/accounts.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand All @@ -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: [
Expand All @@ -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);
}
Expand Down
9 changes: 4 additions & 5 deletions src/account/services/bcl-pnl.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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);
Expand Down
7 changes: 1 addition & 6 deletions src/affiliation/controllers/affiliation.controller.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand All @@ -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 },
Expand Down
6 changes: 3 additions & 3 deletions src/api-core/base/base.resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -25,7 +25,7 @@ export function createBaseResolver<T>(config: EntityConfig<T>) {
@Resolver(() => config.entity)
class BaseResolver {
public readonly repository: Repository<T>;
public readonly relatedRepositories: Map<Function, Repository<any>>;
public readonly relatedRepositories: Map<Type<any>, Repository<any>>;

constructor(
repository: Repository<T>,
Expand All @@ -36,7 +36,7 @@ export function createBaseResolver<T>(config: EntityConfig<T>) {
@Optional() repo4?: Repository<any>,
) {
this.repository = repository;
this.relatedRepositories = new Map<Function, Repository<any>>();
this.relatedRepositories = new Map<Type<any>, Repository<any>>();

// Map related repositories by entity type (only use the ones that exist)
const repos = [repo0, repo1, repo2, repo3, repo4];
Expand Down
15 changes: 8 additions & 7 deletions src/app.controller.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}),
},
},
{
Expand Down Expand Up @@ -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,
}),
);
});
});
3 changes: 2 additions & 1 deletion src/configs/social.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
37 changes: 37 additions & 0 deletions src/dex/controllers/dex-tokens.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -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',
);
});
});
Loading
Loading