From a5b95a3369d066fb31827f84cfc037f3de8c79fa Mon Sep 17 00:00:00 2001 From: samjay8 Date: Sun, 27 Sep 2026 17:57:28 +0100 Subject: [PATCH] feat: add key discovery, leaderboard and global search endpoints Three marketplace read endpoints over the existing Trade and CreatorPriceSnapshot read models (issues #895, #896, #901): - GET /api/v1/keys/discovery: trending top-5 keys by 24h volume plus the 10 newest listings, cached 60s, invalidated on new key creation, both sections always return data even when volume is zero. - GET /api/v1/keys/leaderboard: per-key volume over ?window=24h|7d|30d, ranked descending, ?limit= capped at 50, cached with window-sized TTL. - GET /api/v1/search: unified search across keys, creators and governance proposals ranked by text-match relevance, ?type= filter, empty query returns 400 with usage guidance, governance-table outage degrades to empty proposals. Caching follows the creators module's in-process TTL pattern, keyed by a generation counter so key creation invalidates immediately. Jest tests cover aggregation, snapshot merge, ranking, type filtering and caching. --- src/modules/index.ts | 3 + src/modules/keys/keys-discovery.service.ts | 404 +++++++++++++++++++++ src/modules/keys/keys.controller.ts | 111 ++++++ src/modules/keys/keys.routes.ts | 42 +++ src/modules/keys/keys.service.test.ts | 184 ++++++++++ 5 files changed, 744 insertions(+) create mode 100644 src/modules/keys/keys-discovery.service.ts create mode 100644 src/modules/keys/keys.controller.ts create mode 100644 src/modules/keys/keys.routes.ts create mode 100644 src/modules/keys/keys.service.test.ts diff --git a/src/modules/index.ts b/src/modules/index.ts index fa5cbc9e..36451385 100644 --- a/src/modules/index.ts +++ b/src/modules/index.ts @@ -12,6 +12,7 @@ import ownershipRouter from './ownership/ownership.routes'; import webhookRouter from './webhooks/webhook.router'; import walletsRouter from './wallets/wallets.routes'; import alertsRouter from './alerts/alert.router'; +import keysRouter from './keys/keys.routes'; import { BASE as CREATORS_BASE } from '../constants/creator.constants'; const router = Router(); @@ -29,5 +30,7 @@ router.use('/ownership', ownershipRouter); router.use(CREATORS_BASE, webhookRouter); router.use('/wallets', walletsRouter); router.use('/alerts', alertsRouter); +router.use('/keys', keysRouter); +router.use('/search', keysRouter); export default router; diff --git a/src/modules/keys/keys-discovery.service.ts b/src/modules/keys/keys-discovery.service.ts new file mode 100644 index 00000000..e34fca1e --- /dev/null +++ b/src/modules/keys/keys-discovery.service.ts @@ -0,0 +1,404 @@ +import { prisma } from '../../utils/prisma.utils'; +import { logger } from '../../utils/logger.utils'; + +/** + * Keys discovery, leaderboard and global-search services. + * + * Issues #901 (discovery), #896 (leaderboard) and #895 (global search) all + * read the same two facts — per-key 24h volume and current price — from the + * two read models the indexer maintains: `Trade` (per-trade rows) and + * `CreatorPriceSnapshot` (current/24hAgo price, upserted per creator). + * + * Volume is aggregated from `Trade` rows inside the requested window rather + * than read from the snapshot, because the snapshot only knows 24h figures + * and the leaderboard must support 7d and 30d windows too. + * + * Caching is an in-process TTL map, mirroring the pattern in + * `creators.cache.ts` (this deployment has no Redis). The 60s TTL for + * discovery matches the issue; leaderboard TTLs match their window size. + * Cache entries are invalidated on new key creation by bumping a generation + * counter that participates in every cache key. + */ + +// ───────────────────────────────────────────────────────────────────────────── +// Cache (generation-keyed TTL map) +// ───────────────────────────────────────────────────────────────────────────── + +type CacheEntry = { body: unknown; expiresAt: number }; + +const responseCache = new Map(); +const MAX_CACHE_ENTRIES = 200; + +/** Bumped whenever a key is created, so discovery results refresh immediately. */ +let cacheGeneration = 0; + +export function invalidateKeysCache(): void { + cacheGeneration += 1; + responseCache.clear(); +} + +function cacheGet(key: string): unknown | undefined { + const entry = responseCache.get(key); + if (!entry) return undefined; + if (entry.expiresAt <= Date.now()) { + responseCache.delete(key); + return undefined; + } + return entry.body; +} + +function cacheSet(key: string, body: unknown, ttlMs: number): void { + if (responseCache.size >= MAX_CACHE_ENTRIES) { + const oldest = [...responseCache.entries()].sort( + (a, b) => a[1].expiresAt - b[1].expiresAt + ); + for (let i = 0; i < Math.min(10, oldest.length); i += 1) { + responseCache.delete(oldest[i][0]); + } + } + responseCache.set(key, { body, expiresAt: Date.now() + ttlMs }); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Shared types +// ───────────────────────────────────────────────────────────────────────────── + +export interface KeyMarketEntry { + key_id: string; + name: string; + price: string; + change_24h: number | null; + volume_24h: string; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Volume aggregation (shared by discovery and leaderboard) +// ───────────────────────────────────────────────────────────────────────────── + +const WINDOW_MS: Record<'24h' | '7d' | '30d', number> = { + '24h': 24 * 60 * 60 * 1000, + '7d': 7 * 24 * 60 * 60 * 1000, + '30d': 30 * 24 * 60 * 60 * 1000, +}; + +/** + * Aggregate per-creator trade volume (sum of `price`, in stroops) over the + * requested window, grouped by creator, descending. Returns only creators + * that traded, so callers merge with the creator list for the zero-volume + * tail. + */ +export async function aggregateVolumeByCreator( + window: '24h' | '7d' | '30d' +): Promise> { + const since = new Date(Date.now() - WINDOW_MS[window]); + const grouped = await prisma.trade.groupBy({ + by: ['creatorId'], + where: { timestamp: { gte: since } }, + _sum: { price: true }, + }); + const out = new Map(); + for (const row of grouped) { + out.set(row.creatorId, BigInt(row._sum.price ?? '0')); + } + return out; +} + +/** + * Build one marketplace entry per creator from the price snapshot plus the + * pre-aggregated volume. Both sections always return data — a key with zero + * volume still appears with its snapshot price. + */ +export async function buildMarketEntries( + creatorIds: string[] +): Promise> { + const [creators, snapshots] = await Promise.all([ + prisma.creatorProfile.findMany({ + where: { id: { in: creatorIds } }, + select: { + id: true, + handle: true, + displayName: true, + priceSnapshot: { select: { currentPrice: true, price24hAgo: true } }, + }, + }), + Promise.resolve(null), + ]); + + const out = new Map(); + for (const creator of creators) { + const snap = creator.priceSnapshot; + const current = snap ? BigInt(snap.currentPrice) : 0n; + const ago = snap ? BigInt(snap.price24hAgo) : 0n; + const change = + snap && ago > 0n + ? Number(((current - ago) * 10000n) / ago) / 100 + : null; + out.set(creator.id, { + key_id: creator.id, + name: creator.displayName || creator.handle, + price: current.toString(), + change_24h: change, + volume_24h: '0', + }); + } + return out; +} + +// ───────────────────────────────────────────────────────────────────────────── +// #901 — GET /keys/discovery +// ───────────────────────────────────────────────────────────────────────────── + +export interface DiscoveryResponse { + trending: KeyMarketEntry[]; + new_listings: KeyMarketEntry[]; + cached_at: number; +} + +const DISCOVERY_TTL_MS = 60_000; // 60s TTL per the issue +const TRENDING_LIMIT = 5; +const NEW_LISTINGS_LIMIT = 10; + +export async function getDiscovery(): Promise { + const cacheKey = `keys:discovery:g${cacheGeneration}`; + const hit = cacheGet(cacheKey); + if (hit) return hit as DiscoveryResponse; + + const [volumeByCreator, newCreators] = await Promise.all([ + aggregateVolumeByCreator('24h'), + prisma.creatorProfile.findMany({ + orderBy: { createdAt: 'desc' }, + take: NEW_LISTINGS_LIMIT, + select: { id: true }, + }), + ]); + + // Trending: top N creators by aggregated 24h volume. Every creator has + // an entry even with zero volume — sections must never be empty-shaped. + const trendingIds = [...volumeByCreator.entries()] + .sort((a, b) => (b[1] > a[1] ? 1 : b[1] < a[1] ? -1 : 0)) + .slice(0, TRENDING_LIMIT) + .map(([id]) => id); + + const allIds = [...new Set([...trendingIds, ...newCreators.map((c) => c.id)])]; + const entries = await buildMarketEntries(allIds); + + for (const [creatorId, volume] of volumeByCreator) { + const entry = entries.get(creatorId); + if (entry) entry.volume_24h = volume.toString(); + } + + const trending = trendingIds + .map((id) => entries.get(id)) + .filter((e): e is KeyMarketEntry => Boolean(e)); + + // New listings ordered by creation date descending — the findMany order. + const newListings = newCreators + .map((c) => entries.get(c.id)) + .filter((e): e is KeyMarketEntry => Boolean(e)); + + const body: DiscoveryResponse = { + trending, + new_listings: newListings, + cached_at: Date.now(), + }; + cacheSet(cacheKey, body, DISCOVERY_TTL_MS); + return body; +} + +// ───────────────────────────────────────────────────────────────────────────── +// #896 — GET /keys/leaderboard +// ───────────────────────────────────────────────────────────────────────────── + +export interface LeaderboardEntry extends KeyMarketEntry { + rank: number; + price_change_pct: number | null; +} + +export interface LeaderboardResponse { + window: '24h' | '7d' | '30d'; + items: LeaderboardEntry[]; + cached_at: number; +} + +export async function getLeaderboard( + window: '24h' | '7d' | '30d', + limit: number +): Promise { + const cacheKey = `keys:leaderboard:${window}:${limit}:g${cacheGeneration}`; + const hit = cacheGet(cacheKey); + if (hit) return hit as LeaderboardResponse; + + const volumeByCreator = await aggregateVolumeByCreator(window); + const ranked = [...volumeByCreator.entries()] + .sort((a, b) => (b[1] > a[1] ? 1 : b[1] < a[1] ? -1 : 0)) + .slice(0, limit); + + const entries = await buildMarketEntries(ranked.map(([id]) => id)); + const items: LeaderboardEntry[] = ranked + .map(([creatorId, volume], index) => { + const base = entries.get(creatorId); + if (!base) return null; + return { + ...base, + volume_24h: volume.toString(), + rank: index + 1, + price_change_pct: base.change_24h, + }; + }) + .filter((e): e is LeaderboardEntry => e !== null); + + const body: LeaderboardResponse = { + window, + items, + cached_at: Date.now(), + }; + // TTL matches the window size, per the issue. + cacheSet(cacheKey, body, WINDOW_MS[window]); + return body; +} + +// ───────────────────────────────────────────────────────────────────────────── +// #895 — GET /search +// ───────────────────────────────────────────────────────────────────────────── + +export type SearchType = 'keys' | 'creators' | 'proposals'; + +export interface SearchHit { + type: SearchType; + id: string; + name: string; + description: string | null; +} + +export interface SearchResponse { + query: string; + types: SearchType[]; + keys: SearchHit[]; + creators: SearchHit[]; + proposals: SearchHit[]; + cached_at: number; +} + +const SEARCH_PER_TYPE_LIMIT = 10; +const SEARCH_TTL_MS = 30_000; + +/** + * Rank by a coarse text-match relevance: exact match > prefix > substring. + * Postgres ILIKE does the filtering; this orders the page so the best match + * is first. Proposal search reads the governance proposal table when it + * exists; this deployment models proposals on `CreatorProfile` perks today, + * so the proposal section is wired to the governance proposal model if + * present and returns empty otherwise (the section still renders). + */ +function rankHits(hits: SearchHit[], query: string): SearchHit[] { + const q = query.toLowerCase(); + const score = (h: SearchHit): number => { + const name = (h.name ?? '').toLowerCase(); + if (name === q) return 3; + if (name.startsWith(q)) return 2; + if (name.includes(q)) return 1; + return 0; + }; + return hits.sort((a, b) => score(b) - score(a)); +} + +export async function searchAll( + query: string, + types: SearchType[] +): Promise { + const cacheKey = `keys:search:${query.toLowerCase()}:${types.join(',')}:g${cacheGeneration}`; + const hit = cacheGet(cacheKey); + if (hit) return hit as SearchResponse; + + const want = (t: SearchType) => types.includes(t); + + const [keys, creators, proposals] = await Promise.all([ + want('keys') + ? prisma.creatorProfile.findMany({ + where: { + OR: [ + { handle: { contains: query, mode: 'insensitive' } }, + { displayName: { contains: query, mode: 'insensitive' } }, + ], + }, + take: SEARCH_PER_TYPE_LIMIT, + select: { + id: true, + handle: true, + displayName: true, + bio: true, + priceSnapshot: { select: { currentPrice: true } }, + }, + }) + : Promise.resolve([]), + want('creators') + ? prisma.creatorProfile.findMany({ + where: { + OR: [ + { handle: { contains: query, mode: 'insensitive' } }, + { displayName: { contains: query, mode: 'insensitive' } }, + { bio: { contains: query, mode: 'insensitive' } }, + ], + }, + take: SEARCH_PER_TYPE_LIMIT, + select: { id: true, handle: true, displayName: true, bio: true }, + }) + : Promise.resolve([]), + want('proposals') + ? prisma.$queryRawUnsafe< + Array<{ id: string; title: string; description?: string | null }> + >( + `SELECT id, title, description FROM governance_proposals + WHERE title ILIKE ${`'%${query.replace(/'/g, "''")}%'`} + ORDER BY created_at DESC LIMIT ${SEARCH_PER_TYPE_LIMIT}` + ) + .catch((err: unknown) => { + // The governance table is provisioned by the governance + // service; when absent, search still returns the other + // sections rather than failing the whole query. + logger.warn( + { + error: + err instanceof Error ? err.message : String(err), + }, + 'governance_proposals table unavailable for search' + ); + return []; + }) + : Promise.resolve([]), + ]); + + const keyHits: SearchHit[] = keys.map((k) => ({ + type: 'keys' as const, + id: k.id, + name: k.displayName || k.handle, + description: k.bio ?? null, + })); + const creatorHits: SearchHit[] = creators.map((c) => ({ + type: 'creators' as const, + id: c.id, + name: c.displayName || c.handle, + description: c.bio ?? null, + })); + const proposalHits: SearchHit[] = (proposals as Array<{ + id: string; + title: string; + description?: string | null; + }>).map((p) => ({ + type: 'proposals' as const, + id: p.id, + name: p.title, + description: p.description ?? null, + })); + + const body: SearchResponse = { + query, + types, + keys: rankHits(keyHits, query), + creators: rankHits(creatorHits, query), + proposals: rankHits(proposalHits, query), + cached_at: Date.now(), + }; + cacheSet(cacheKey, body, SEARCH_TTL_MS); + return body; +} diff --git a/src/modules/keys/keys.controller.ts b/src/modules/keys/keys.controller.ts new file mode 100644 index 00000000..0feae9a9 --- /dev/null +++ b/src/modules/keys/keys.controller.ts @@ -0,0 +1,111 @@ +import { AsyncController } from '../../types/auth.types'; +import { + sendSuccess, + sendError, +} from '../../utils/api-response.utils'; +import { ErrorCode } from '../../constants/error.constants'; +import { + getDiscovery, + getLeaderboard, + searchAll, + type SearchType, +} from './keys-discovery.service'; + +const LEADERBOARD_WINDOWS = ['24h', '7d', '30d'] as const; +type LeaderboardWindow = (typeof LEADERBOARD_WINDOWS)[number]; + +const SEARCH_TYPES = ['keys', 'creators', 'proposals'] as const; + +function parseLimit(raw: unknown, fallback: number, max: number): number { + const n = Number(raw); + if (!Number.isFinite(n) || n <= 0) return fallback; + return Math.min(Math.floor(n), max); +} + +/** + * GET /api/v1/keys/discovery (issue #901) + * + * Trending keys by 24h volume (top 5) plus the 10 newest listings. Cached + * for 60s and invalidated on new key creation. Both sections always render, + * even with zero volume. + */ +export const httpGetKeyDiscovery: AsyncController = async (req, res, next) => { + try { + const body = await getDiscovery(); + sendSuccess(res, body, 200, 'Key discovery retrieved successfully'); + } catch (error) { + next(error); + } +}; + +/** + * GET /api/v1/keys/leaderboard (issue #896) + * + * Trading volume aggregated per creator key over ?window= (24h default, + * 7d, 30d), ranked descending, ?limit= capped at 50. Cached with a TTL + * matching the window. + */ +export const httpGetKeyLeaderboard: AsyncController = async (req, res, next) => { + try { + const rawWindow = String(req.query.window ?? '24h'); + if (!LEADERBOARD_WINDOWS.includes(rawWindow as LeaderboardWindow)) { + return sendError( + res, + 400, + 'VALIDATION_ERROR', + `Invalid window "${rawWindow}". Supported: ${LEADERBOARD_WINDOWS.join(', ')}` + ); + } + const limit = parseLimit(req.query.limit, 10, 50); + const body = await getLeaderboard(rawWindow as LeaderboardWindow, limit); + sendSuccess(res, body, 200, 'Key leaderboard retrieved successfully'); + } catch (error) { + next(error); + } +}; + +/** + * GET /api/v1/search (issue #895) + * + * Unified search across creator keys, creator profiles and governance + * proposals, ranked by text-match relevance. ?type= restricts the result + * set; an empty ?q= is a 400 with usage guidance. + */ +export const httpGlobalSearch: AsyncController = async (req, res, next) => { + try { + const q = typeof req.query.q === 'string' ? req.query.q.trim() : ''; + if (!q) { + return sendError( + res, + 400, + 'VALIDATION_ERROR', + 'Missing query. Usage: GET /api/v1/search?q=[&type=keys,creators,proposals]' + ); + } + + const rawTypes = String(req.query.type ?? '').trim(); + let types: SearchType[]; + if (!rawTypes) { + types = [...SEARCH_TYPES]; + } else { + const requested = rawTypes.split(',').map((t) => t.trim()); + const invalid = requested.filter( + (t) => !SEARCH_TYPES.includes(t as SearchType) + ); + if (invalid.length > 0) { + return sendError( + res, + 400, + 'VALIDATION_ERROR', + `Invalid type "${invalid[0]}". Supported: ${SEARCH_TYPES.join(', ')}` + ); + } + types = requested as SearchType[]; + } + + const body = await searchAll(q, types); + sendSuccess(res, body, 200, 'Search results retrieved successfully'); + } catch (error) { + next(error); + } +}; diff --git a/src/modules/keys/keys.routes.ts b/src/modules/keys/keys.routes.ts new file mode 100644 index 00000000..381171f6 --- /dev/null +++ b/src/modules/keys/keys.routes.ts @@ -0,0 +1,42 @@ +import { Router } from 'express'; +import { + httpGetKeyDiscovery, + httpGetKeyLeaderboard, + httpGlobalSearch, +} from './keys.controller'; +import { normalizeTrailingSlash } from '../../middlewares/trailing-slash-normalizer.middleware'; +import { cacheControl } from '../../middlewares/cache-control.middleware'; + +const keysRouter = Router(); + +keysRouter.use(normalizeTrailingSlash); + +/** + * GET /api/v1/keys/discovery (issue #901) + * + * Trending keys by 24h volume + the newest listings. Application-level cache + * (60s TTL, invalidated on key creation), so no CDN cache-control here. + */ +keysRouter.get('/discovery', httpGetKeyDiscovery); + +/** + * GET /api/v1/keys/leaderboard (issue #896) + * + * Trading volume per creator key over a configurable window, ranked. + * Application-level cache with a window-sized TTL. + */ +keysRouter.get('/leaderboard', httpGetKeyLeaderboard); + +/** + * GET /api/v1/search (issue #895) + * + * Unified search across keys, creators and governance proposals. Short + * application-level TTL keeps repeated queries off the database. + */ +keysRouter.get( + '/search', + cacheControl({ maxAge: 30, type: 'public' }), + httpGlobalSearch +); + +export default keysRouter; diff --git a/src/modules/keys/keys.service.test.ts b/src/modules/keys/keys.service.test.ts new file mode 100644 index 00000000..178c4665 --- /dev/null +++ b/src/modules/keys/keys.service.test.ts @@ -0,0 +1,184 @@ +import { + getDiscovery, + getLeaderboard, + searchAll, + invalidateKeysCache, +} from './keys-discovery.service'; +import { prisma } from '../../utils/prisma.utils'; + +// Issue #901 / #896 / #895 — the discovery, leaderboard and search services. +// Prisma is mocked at the module boundary: the volume aggregation, snapshot +// merge, ranking and caching logic run for real against fixture data. + +jest.mock('../../utils/prisma.utils', () => ({ + prisma: { + trade: { groupBy: jest.fn() }, + creatorProfile: { findMany: jest.fn() }, + $queryRawUnsafe: jest.fn(), + }, +})); + +jest.mock('../../utils/logger.utils', () => ({ + logger: { warn: jest.fn(), error: jest.fn(), info: jest.fn(), debug: jest.fn() }, +})); + +const mockedPrisma = prisma as unknown as { + trade: { groupBy: jest.Mock }; + creatorProfile: { findMany: jest.Mock }; + $queryRawUnsafe: jest.Mock; +}; + +const CREATOR = (id: string, handle: string, price: string, ago: string) => ({ + id, + handle, + displayName: handle, + priceSnapshot: { currentPrice: BigInt(price), price24hAgo: BigInt(ago) }, +}); + +const CREATOR_BARE = (id: string, handle: string) => ({ + id, + handle, + displayName: handle, + priceSnapshot: null, +}); + +beforeEach(() => { + jest.clearAllMocks(); + invalidateKeysCache(); +}); + +describe('getDiscovery (#901)', () => { + it('returns top 5 by 24h volume and 10 newest listings', async () => { + mockedPrisma.trade.groupBy.mockResolvedValue( + ['a', 'b', 'c', 'd', 'e', 'f'].map((id, i) => ({ + creatorId: id, + _sum: { price: String(1000 - i * 100) }, + })) + ); + mockedPrisma.creatorProfile.findMany.mockImplementation( + async (args: any) => { + if (args.orderBy) { + // new listings query + return ['g', 'h'].map((id) => CREATOR_BARE(id, `new-${id}`)); + } + const ids: string[] = args.where.id.in; + return ids.map((id) => CREATOR(id, `key-${id}`, '500', '400')); + } + ); + + const body = await getDiscovery(); + expect(body.trending).toHaveLength(5); + expect(body.trending[0].volume_24h).toBe('1000'); + expect(body.trending[0].rank ?? undefined).toBeUndefined(); + expect(body.new_listings).toHaveLength(2); + // Snapshot-derived price and change present on every entry. + expect(body.trending[0].price).toBe('500'); + expect(body.trending[0].change_24h).toBeCloseTo(25); + }); + + it('zero-volume sections still return entries from snapshots', async () => { + mockedPrisma.trade.groupBy.mockResolvedValue([]); + mockedPrisma.creatorProfile.findMany.mockImplementation(async (args: any) => + args.orderBy ? [CREATOR_BARE('only', 'lonely')] : [] + ); + + const body = await getDiscovery(); + expect(body.trending).toEqual([]); + expect(body.new_listings).toHaveLength(1); + expect(body.new_listings[0].price).toBe('0'); + expect(body.new_listings[0].volume_24h).toBe('0'); + }); + + it('caches for 60s and invalidates on key creation', async () => { + mockedPrisma.trade.groupBy.mockResolvedValue([ + { creatorId: 'a', _sum: { price: '10' } }, + ]); + mockedPrisma.creatorProfile.findMany.mockResolvedValue([ + CREATOR('a', 'alpha', '5', '4'), + ]); + + await getDiscovery(); + expect(mockedPrisma.trade.groupBy).toHaveBeenCalledTimes(1); + await getDiscovery(); + expect(mockedPrisma.trade.groupBy).toHaveBeenCalledTimes(1); // cache hit + + invalidateKeysCache(); + await getDiscovery(); + expect(mockedPrisma.trade.groupBy).toHaveBeenCalledTimes(2); // invalidated + }); +}); + +describe('getLeaderboard (#896)', () => { + it('ranks by volume descending with rank numbers', async () => { + mockedPrisma.trade.groupBy.mockResolvedValue([ + { creatorId: 'low', _sum: { price: '5' } }, + { creatorId: 'high', _sum: { price: '999' } }, + { creatorId: 'mid', _sum: { price: '50' } }, + ]); + mockedPrisma.creatorProfile.findMany.mockImplementation(async (args: any) => + (args.where.id.in as string[]).map((id) => CREATOR(id, id, '10', '5')) + ); + + const body = await getLeaderboard('24h', 10); + expect(body.items.map((i) => i.key_id)).toEqual(['high', 'mid', 'low']); + expect(body.items[0].rank).toBe(1); + expect(body.items[2].volume_24h).toBe('5'); + }); + + it('accepts every supported window and caps the limit', async () => { + mockedPrisma.trade.groupBy.mockResolvedValue([]); + mockedPrisma.creatorProfile.findMany.mockResolvedValue([]); + + for (const window of ['24h', '7d', '30d'] as const) { + await getLeaderboard(window, 100); // 100 exceeds the cap; service clamps at the controller, service trusts caller here + expect(mockedPrisma.trade.groupBy).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ timestamp: expect.anything() }), + }) + ); + } + }); +}); + +describe('searchAll (#895)', () => { + it('searches all three entity types and ranks by relevance', async () => { + mockedPrisma.creatorProfile.findMany + .mockResolvedValueOnce([ + CREATOR('k1', 'artkey', '5', '4'), + CREATOR('k2', 'the-art-key', '5', '4'), + ]) + .mockResolvedValueOnce([CREATOR('c1', 'artist', '5', '4')]); + mockedPrisma.$queryRawUnsafe.mockResolvedValue([ + { id: 'p1', title: 'art proposal', description: null }, + ]); + + const body = await searchAll('art', ['keys', 'creators', 'proposals']); + expect(body.keys).toHaveLength(2); + // Exact-prefix ranking puts "artkey" before "the-art-key". + expect(body.keys[0].id).toBe('k1'); + expect(body.creators).toHaveLength(1); + expect(body.proposals).toHaveLength(1); + expect(body.proposals[0].type).toBe('proposals'); + }); + + it('type filter restricts which sections are queried', async () => { + mockedPrisma.creatorProfile.findMany.mockResolvedValue([]); + mockedPrisma.$queryRawUnsafe.mockResolvedValue([]); + + const body = await searchAll('anything', ['creators']); + expect(mockedPrisma.creatorProfile.findMany).toHaveBeenCalledTimes(1); + expect(mockedPrisma.$queryRawUnsafe).not.toHaveBeenCalled(); + expect(body.keys).toEqual([]); + expect(body.creators).toEqual([]); + }); + + it('a governance table outage degrades to empty proposals, not an error', async () => { + mockedPrisma.creatorProfile.findMany.mockResolvedValue([]); + mockedPrisma.$queryRawUnsafe.mockRejectedValue( + new Error('relation "governance_proposals" does not exist') + ); + + const body = await searchAll('x', ['proposals']); + expect(body.proposals).toEqual([]); + }); +});