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
118 changes: 118 additions & 0 deletions src/features/trending/__tests__/trendsSearch.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
/* eslint-disable object-curly-newline */
import {
describe, expect, it, vi,
} from 'vitest';
import { SuperheroApi } from '@/api/backend';
import {
fetchPopularPosts,
fetchTopTraders,
fetchTrendingTokens,
fetchTrendSearchPreview,
fetchTrendSearchSection,
} from '../api/trendsSearch';
import { fetchLeaderboard } from '../api/leaderboard';

vi.mock('@/api/backend', () => ({
SuperheroApi: {
fetchJson: vi.fn(),
listTokens: vi.fn(),
listPosts: vi.fn(),
listPopularPosts: vi.fn(),
},
}));

vi.mock('../api/leaderboard', () => ({
fetchLeaderboard: vi.fn(),
}));

describe('trendsSearch api helpers', () => {
it('loads preview results from all three search endpoints', async () => {
vi.mocked(SuperheroApi.listTokens).mockResolvedValueOnce({
items: [{ address: 'ct_token', sale_address: 'ct_sale', name: 'HELLO' }],
meta: { totalItems: 8, totalPages: 3, currentPage: 1 },
} as any);
vi.mocked(SuperheroApi.fetchJson).mockResolvedValueOnce({
items: [{ address: 'ak_user', chain_name: 'hello.chain' }],
meta: { totalItems: 5, totalPages: 2, currentPage: 1 },
} as any);
vi.mocked(SuperheroApi.listPosts).mockResolvedValueOnce({
items: [{ id: 'post_1_v3', sender_address: 'ak_user', content: 'hello post', media: [], topics: [], total_comments: 0, tx_hash: '', tx_args: [], contract_address: '', type: 'post', created_at: '2026-03-27T12:00:00.000Z' }],
meta: { totalItems: 4, totalPages: 2, currentPage: 1 },
} as any);

const result = await fetchTrendSearchPreview('hello');

expect(SuperheroApi.listTokens).toHaveBeenCalledWith({
search: 'hello',
limit: 3,
page: 1,
orderBy: 'market_cap',
orderDirection: 'DESC',
});
expect(SuperheroApi.fetchJson).toHaveBeenCalledWith('/api/accounts?limit=3&search=hello');
expect(SuperheroApi.listPosts).toHaveBeenCalledWith({
search: 'hello',
limit: 3,
page: 1,
orderBy: 'created_at',
orderDirection: 'DESC',
});
expect(result.tokens.meta.totalItems).toBe(8);
expect(result.users.items[0].address).toBe('ak_user');
expect(result.posts.items[0].id).toBe('post_1_v3');
});

it('loads a full section result set for users', async () => {
vi.mocked(SuperheroApi.fetchJson).mockResolvedValueOnce({
items: [{ address: 'ak_full', chain_name: 'full.chain' }],
meta: { totalItems: 14, totalPages: 1, currentPage: 1 },
} as any);

const result = await fetchTrendSearchSection('users', 'full');

expect(SuperheroApi.fetchJson).toHaveBeenCalledWith('/api/accounts?limit=24&search=full');
expect(result.meta.totalItems).toBe(14);
expect(result.items[0].address).toBe('ak_full');
});

it('loads fallback content for tokens, posts and traders', async () => {
vi.mocked(SuperheroApi.listTokens).mockResolvedValueOnce({
items: [{ address: 'ct_fallback', sale_address: 'ct_fallback_sale', name: 'TREND' }],
} as any);
vi.mocked(SuperheroApi.listPopularPosts).mockResolvedValueOnce({
items: [{ id: 'popular_v3', sender_address: 'ak_popular', content: 'popular', media: [], topics: [], total_comments: 2, tx_hash: '', tx_args: [], contract_address: '', type: 'post', created_at: '2026-03-27T12:00:00.000Z' }],
} as any);
vi.mocked(fetchLeaderboard).mockResolvedValueOnce({
items: [{ address: 'ak_trader', pnl_usd: 1200 }],
meta: { totalItems: 1, totalPages: 1, currentPage: 1 },
});

const [tokens, posts, traders] = await Promise.all([
fetchTrendingTokens(3),
fetchPopularPosts(3),
fetchTopTraders(3),
]);

expect(SuperheroApi.listTokens).toHaveBeenCalledWith({
limit: 3,
page: 1,
orderBy: 'trending_score',
orderDirection: 'DESC',
});
expect(SuperheroApi.listPopularPosts).toHaveBeenCalledWith({
window: 'all',
limit: 3,
page: 1,
});
expect(fetchLeaderboard).toHaveBeenCalledWith({
timeframe: '7d',
metric: 'pnl',
page: 1,
limit: 3,
sortDir: 'DESC',
});
expect(tokens.items[0].name).toBe('TREND');
expect(posts.items[0].id).toBe('popular_v3');
expect(traders.items[0].address).toBe('ak_trader');
});
});
193 changes: 193 additions & 0 deletions src/features/trending/api/trendsSearch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
import type { PostDto, TokenDto } from '@/api/generated';
import { SuperheroApi } from '@/api/backend';
import {
fetchLeaderboard,
type LeaderboardItem,
} from './leaderboard';

export type SearchTab = 'tokens' | 'users' | 'posts';

export type SearchMeta = {
totalItems: number;
totalPages: number;
currentPage: number;
};

export type SearchSection<T> = {
items: T[];
meta: SearchMeta;
};

export type TrendTokenItem = TokenDto;

export type TrendUserItem = {
address: string;
bio?: string | null;
chain_name?: string | null;
chain_name_updated_at?: string | null;
total_volume?: string | number | null;
total_tx_count?: number | null;
total_buy_tx_count?: number | null;
total_sell_tx_count?: number | null;
total_created_tokens?: number | null;
created_at?: string | null;
};

export type TrendPostItem = PostDto & {
slug?: string | null;
sender?: {
address?: string;
public_name?: string | null;
bio?: string | null;
avatarurl?: string | null;
} | null;
token_mentions?: string[];
};

export const SEARCH_PREVIEW_LIMIT = 3;
export const SEARCH_FULL_LIMIT = 24;
export const DEFAULT_TAB_LIMIT = 12;
export const FALLBACK_LIMIT = 3;

type PaginatedApiResponse<T> = {
items?: T[];
meta?: {
totalItems?: number;
totalPages?: number;
currentPage?: number;
page?: number;
};
};

function normalizeSection<T>(
response: PaginatedApiResponse<T> | T[] | null | undefined,
): SearchSection<T> {
if (Array.isArray(response)) {
return {
items: response,
meta: {
totalItems: response.length,
totalPages: 1,
currentPage: 1,
},
};
}

const items = response?.items ?? [];
const totalItems = response?.meta?.totalItems ?? items.length;
const totalPages = response?.meta?.totalPages ?? 1;
const currentPage = response?.meta?.currentPage ?? response?.meta?.page ?? 1;

return {
items,
meta: {
totalItems,
totalPages,
currentPage,
},
};
}

async function fetchAccountSearch(limit: number, search?: string) {
const params = new URLSearchParams();
params.set('limit', String(limit));

if (search?.trim()) {
params.set('search', search.trim());
}

const suffix = params.toString();
return SuperheroApi.fetchJson(`/api/accounts${suffix ? `?${suffix}` : ''}`) as Promise<
PaginatedApiResponse<TrendUserItem>
>;
}

function settledValue<T>(
result: PromiseSettledResult<T>,
): T | undefined {
return result.status === 'fulfilled' ? result.value : undefined;
}

export async function fetchTrendSearchPreview(search: string) {
const term = search.trim();

const [tokens, users, posts] = await Promise.allSettled([
SuperheroApi.listTokens({
search: term,
limit: SEARCH_PREVIEW_LIMIT,
page: 1,
orderBy: 'market_cap',
orderDirection: 'DESC',
}) as Promise<PaginatedApiResponse<TrendTokenItem>>,
fetchAccountSearch(SEARCH_PREVIEW_LIMIT, term),
SuperheroApi.listPosts({
search: term,
limit: SEARCH_PREVIEW_LIMIT,
page: 1,
orderBy: 'created_at',
orderDirection: 'DESC',
}) as Promise<PaginatedApiResponse<TrendPostItem>>,
]);

return {
tokens: normalizeSection(settledValue(tokens)),
users: normalizeSection(settledValue(users)),
posts: normalizeSection(settledValue(posts)),
};
}

export async function fetchTrendSearchSection(tab: SearchTab, search: string) {
const term = search.trim();

switch (tab) {
case 'tokens':
return normalizeSection<TrendTokenItem>(await SuperheroApi.listTokens({
search: term,
limit: SEARCH_FULL_LIMIT,
page: 1,
orderBy: 'market_cap',
orderDirection: 'DESC',
}) as PaginatedApiResponse<TrendTokenItem>);
case 'users':
return normalizeSection<TrendUserItem>(await fetchAccountSearch(SEARCH_FULL_LIMIT, term));
case 'posts':
return normalizeSection<TrendPostItem>(await SuperheroApi.listPosts({
search: term,
limit: SEARCH_FULL_LIMIT,
page: 1,
orderBy: 'created_at',
orderDirection: 'DESC',
}) as PaginatedApiResponse<TrendPostItem>);
default: {
const exhaustive: never = tab;
throw new Error(`Unknown search tab: ${exhaustive}`);
}
}
}

export async function fetchTrendingTokens(limit: number = DEFAULT_TAB_LIMIT) {
return normalizeSection<TrendTokenItem>(await SuperheroApi.listTokens({
limit,
page: 1,
orderBy: 'trending_score',
orderDirection: 'DESC',
}) as PaginatedApiResponse<TrendTokenItem>);
}

export async function fetchPopularPosts(limit: number = DEFAULT_TAB_LIMIT) {
return normalizeSection<TrendPostItem>(await SuperheroApi.listPopularPosts({
window: 'all',
limit,
page: 1,
}) as PaginatedApiResponse<TrendPostItem>);
}

export async function fetchTopTraders(limit: number = DEFAULT_TAB_LIMIT) {
return fetchLeaderboard({
timeframe: '7d',
metric: 'pnl',
page: 1,
limit,
sortDir: 'DESC',
}) as Promise<SearchSection<LeaderboardItem>>;
}
Loading
Loading