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
5 changes: 5 additions & 0 deletions src/constants/creator-public-cache.constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,11 @@ export const CREATOR_PUBLIC_ROUTE_CACHE_PRESETS = {
type: 'public' as const,
staleIfError: 86400,
},
[CREATOR_PUBLIC_ROUTE_NAMES.GET_HOLDERS]: {
maxAge: publicReadSeconds,
type: 'public' as const,
staleIfError: 86400,
},
} as const;

/**
Expand Down
2 changes: 2 additions & 0 deletions src/constants/creator-public-routes.constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,6 @@ export const CREATOR_PUBLIC_ROUTE_NAMES = {
UPSERT_PROFILE: 'creators:profile:upsert',
/** GET /api/v1/creators/:creatorId/stats - Public creator stats */
GET_STATS: 'creators:stats:get',
/** GET /api/v1/creators/:creatorId/holders - Paginated key holder list */
GET_HOLDERS: 'creators:holders:get',
} as const;
58 changes: 58 additions & 0 deletions src/modules/creators/creator-holders.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { AsyncController } from '../../types/auth.types';
import { CreatorHoldersQuerySchema } from './creator-holders.schemas';
import {
findCreatorByIdOrHandle,
fetchCreatorHolders,
} from './creator-holders.service';
import {
sendSuccess,
sendNotFound,
sendValidationError,
} from '../../utils/api-response.utils';
import { attachTimestampHeader } from '../../utils/timestamp-headers.utils';
import { parsePublicQuery } from '../../utils/public-query-parse.utils';
import { buildOffsetPaginationMeta } from '../../utils/pagination.utils';

/**
* Controller for GET /api/v1/creators/:id/holders
*
* Returns a paginated list of wallets that hold keys for the given creator,
* along with each wallet's current balance and the timestamp of their first buy.
*
* - Returns 404 if the creator does not exist.
* - Returns an empty items array (not 404) if the creator exists but has no holders.
* - Default sort: largest key_balance first.
* - Optional ?sort=held_since returns earliest buyers first.
*/
export const httpGetCreatorHolders: AsyncController = async (req, res, next) => {
try {
const rawId = req.params['id'];
const id = typeof rawId === 'string' ? rawId : String(rawId ?? '');

const parsed = parsePublicQuery(CreatorHoldersQuerySchema, req.query, {
debugContext: 'creator-holders-query',
});

if (!parsed.ok) {
return sendValidationError(res, 'Invalid query parameters', parsed.details);
}

const creator = await findCreatorByIdOrHandle(id);
if (!creator) {
return sendNotFound(res, 'Creator');
}

const [holders, total] = await fetchCreatorHolders(creator.id, parsed.data);

const meta = buildOffsetPaginationMeta({
limit: parsed.data.limit,
offset: parsed.data.offset,
total,
});

attachTimestampHeader(res);
sendSuccess(res, { items: holders, meta });
} catch (error) {
next(error);
}
};
268 changes: 268 additions & 0 deletions src/modules/creators/creator-holders.integration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,268 @@
// Integration test: GET /creators/:id/holders
//
// Exercises the full flow of the creator holders endpoint:
// 1. 404 when creator does not exist
// 2. Empty items list when creator exists but has no holders
// 3. Paginated holder list sorted by key_balance desc (default)
// 4. Holder list sorted by held_since asc (?sort=held_since)
// 5. Pagination meta (hasMore, total, limit, offset)
//
// Uses Jest mocks — no database required.

import { httpGetCreatorHolders } from './creator-holders.controller';
import * as holdersService from './creator-holders.service';
import type { HolderRecord } from './creator-holders.service';

// ── Lightweight request/response mocks ────────────────────────────────────────

function makeReq(
params: Record<string, string> = {},
query: Record<string, string> = {},
): any {
return { params, query };
}

function makeRes(): any {
const res: any = {};
res.status = jest.fn().mockReturnValue(res);
res.json = jest.fn().mockReturnValue(res);
res.setHeader = jest.fn().mockReturnValue(res);
res.set = jest.fn().mockReturnValue(res);
return res;
}

function makeNext(): jest.Mock {
return jest.fn();
}

// ── Fixtures ──────────────────────────────────────────────────────────────────

const CREATOR_STUB = { id: 'creator-cuid-1', handle: 'alice' };

function makeHolder(
index: number,
overrides: Partial<HolderRecord> = {},
): HolderRecord {
return {
wallet_address: `GWALLETADDRESS${String(index).padStart(46, '0')}`,
key_balance: (4 - index) * 10, // 30, 20, 10 for indices 1, 2, 3
held_since: new Date(`2024-0${index}-01T00:00:00.000Z`),
...overrides,
};
}

const HOLDER_A = makeHolder(1); // balance: 30, earliest buyer
const HOLDER_B = makeHolder(2); // balance: 20
const HOLDER_C = makeHolder(3); // balance: 10, latest buyer

const ALL_HOLDERS = [HOLDER_A, HOLDER_B, HOLDER_C];

// ── Tests ─────────────────────────────────────────────────────────────────────

describe('GET /creators/:id/holders', () => {
afterEach(() => {
jest.restoreAllMocks();
});

// ── Creator not found ──────────────────────────────────────────────────────

it('returns 404 when the creator does not exist', async () => {
jest
.spyOn(holdersService, 'findCreatorByIdOrHandle')
.mockResolvedValue(null);

const req = makeReq({ id: 'nonexistent-creator' });
const res = makeRes();
await httpGetCreatorHolders(req, res, makeNext());

expect(res.status).toHaveBeenCalledWith(404);

const body = res.json.mock.calls[0][0];
expect(body.success).toBe(false);
expect(body.error.code).toBe('NOT_FOUND');
});

// ── Creator exists but no holders ─────────────────────────────────────────

it('returns empty items list (not 404) when creator exists but has no holders', async () => {
jest
.spyOn(holdersService, 'findCreatorByIdOrHandle')
.mockResolvedValue(CREATOR_STUB);
jest
.spyOn(holdersService, 'fetchCreatorHolders')
.mockResolvedValue([[], 0]);

const req = makeReq({ id: CREATOR_STUB.handle });
const res = makeRes();
await httpGetCreatorHolders(req, res, makeNext());

expect(res.status).toHaveBeenCalledWith(200);

const body = res.json.mock.calls[0][0];
expect(body.success).toBe(true);
expect(body.data.items).toEqual([]);
expect(body.data.meta.total).toBe(0);
expect(body.data.meta.hasMore).toBe(false);
});

// ── Default sort: key_balance desc ────────────────────────────────────────

it('returns holders sorted by key_balance desc by default', async () => {
jest
.spyOn(holdersService, 'findCreatorByIdOrHandle')
.mockResolvedValue(CREATOR_STUB);
jest
.spyOn(holdersService, 'fetchCreatorHolders')
.mockResolvedValue([ALL_HOLDERS, ALL_HOLDERS.length]);

const req = makeReq({ id: CREATOR_STUB.id });
const res = makeRes();
await httpGetCreatorHolders(req, res, makeNext());

expect(res.status).toHaveBeenCalledWith(200);

const body = res.json.mock.calls[0][0];
const items = body.data.items as HolderRecord[];

expect(items).toHaveLength(3);
expect(items[0].wallet_address).toBe(HOLDER_A.wallet_address);
expect(items[0].key_balance).toBe(HOLDER_A.key_balance); // 30
expect(items[2].key_balance).toBe(HOLDER_C.key_balance); // 10

// Verify fetchCreatorHolders was called with sort=key_balance
const [, query] = (holdersService.fetchCreatorHolders as jest.Mock).mock.calls[0];
expect(query.sort).toBe('key_balance');
});

// ── Sort by held_since ─────────────────────────────────────────────────────

it('returns holders sorted by held_since asc when ?sort=held_since', async () => {
jest
.spyOn(holdersService, 'findCreatorByIdOrHandle')
.mockResolvedValue(CREATOR_STUB);
jest
.spyOn(holdersService, 'fetchCreatorHolders')
.mockResolvedValue([ALL_HOLDERS, ALL_HOLDERS.length]);

const req = makeReq({ id: CREATOR_STUB.id }, { sort: 'held_since' });
const res = makeRes();
await httpGetCreatorHolders(req, res, makeNext());

expect(res.status).toHaveBeenCalledWith(200);

const body = res.json.mock.calls[0][0];
expect(body.success).toBe(true);

// Verify fetchCreatorHolders was called with sort=held_since
const [, query] = (holdersService.fetchCreatorHolders as jest.Mock).mock.calls[0];
expect(query.sort).toBe('held_since');
});

// ── Pagination ─────────────────────────────────────────────────────────────

it('returns correct pagination meta for a partial page', async () => {
const TOTAL = 10;
const PAGE_ITEMS = ALL_HOLDERS; // 3 of 10

jest
.spyOn(holdersService, 'findCreatorByIdOrHandle')
.mockResolvedValue(CREATOR_STUB);
jest
.spyOn(holdersService, 'fetchCreatorHolders')
.mockResolvedValue([PAGE_ITEMS, TOTAL]);

const req = makeReq({ id: CREATOR_STUB.id }, { limit: '3', offset: '0' });
const res = makeRes();
await httpGetCreatorHolders(req, res, makeNext());

const body = res.json.mock.calls[0][0];
const { meta } = body.data;

expect(meta.limit).toBe(3);
expect(meta.offset).toBe(0);
expect(meta.total).toBe(TOTAL);
expect(meta.hasMore).toBe(true);
});

it('returns hasMore=false on the last page', async () => {
jest
.spyOn(holdersService, 'findCreatorByIdOrHandle')
.mockResolvedValue(CREATOR_STUB);
jest
.spyOn(holdersService, 'fetchCreatorHolders')
.mockResolvedValue([ALL_HOLDERS, ALL_HOLDERS.length]);

const req = makeReq({ id: CREATOR_STUB.id }, { limit: '20', offset: '0' });
const res = makeRes();
await httpGetCreatorHolders(req, res, makeNext());

const { meta } = res.json.mock.calls[0][0].data;
expect(meta.hasMore).toBe(false);
expect(meta.total).toBe(3);
});

// ── Response shape ─────────────────────────────────────────────────────────

it('each holder item has wallet_address, key_balance, and held_since', async () => {
jest
.spyOn(holdersService, 'findCreatorByIdOrHandle')
.mockResolvedValue(CREATOR_STUB);
jest
.spyOn(holdersService, 'fetchCreatorHolders')
.mockResolvedValue([[HOLDER_A], 1]);

const req = makeReq({ id: CREATOR_STUB.id });
const res = makeRes();
await httpGetCreatorHolders(req, res, makeNext());

const [item] = res.json.mock.calls[0][0].data.items as HolderRecord[];
expect(item).toHaveProperty('wallet_address', HOLDER_A.wallet_address);
expect(item).toHaveProperty('key_balance', HOLDER_A.key_balance);
expect(item).toHaveProperty('held_since', HOLDER_A.held_since);
});

// ── Validation ─────────────────────────────────────────────────────────────

it('returns 400 for an unknown query param (strict schema)', async () => {
jest
.spyOn(holdersService, 'findCreatorByIdOrHandle')
.mockResolvedValue(CREATOR_STUB);

const req = makeReq({ id: CREATOR_STUB.id }, { unknown_param: 'oops' });
const res = makeRes();
await httpGetCreatorHolders(req, res, makeNext());

expect(res.status).toHaveBeenCalledWith(400);
const body = res.json.mock.calls[0][0];
expect(body.success).toBe(false);
});

it('returns 400 for an invalid sort value', async () => {
jest
.spyOn(holdersService, 'findCreatorByIdOrHandle')
.mockResolvedValue(CREATOR_STUB);

const req = makeReq({ id: CREATOR_STUB.id }, { sort: 'invalid_sort' });
const res = makeRes();
await httpGetCreatorHolders(req, res, makeNext());

expect(res.status).toHaveBeenCalledWith(400);
});

// ── Error propagation ──────────────────────────────────────────────────────

it('calls next(error) when the service throws', async () => {
const dbError = new Error('DB connection failed');
jest
.spyOn(holdersService, 'findCreatorByIdOrHandle')
.mockRejectedValue(dbError);

const req = makeReq({ id: CREATOR_STUB.id });
const res = makeRes();
const next = makeNext();
await httpGetCreatorHolders(req, res, next);

expect(next).toHaveBeenCalledWith(dbError);
expect(res.json).not.toHaveBeenCalled();
});
});
38 changes: 38 additions & 0 deletions src/modules/creators/creator-holders.schemas.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { z } from 'zod';
import { safeIntParam } from '../../utils/query.utils';
import { MIN_PAGE_SIZE, MAX_PAGE_SIZE } from '../../constants/pagination.constants';
import { PUBLIC_OFFSET_PAGINATION_DEFAULTS } from '../../utils/public-list-query-defaults';

/**
* Valid sort fields for the creator holders endpoint.
* - key_balance: sort by number of keys held (default, largest first)
* - held_since: sort by when the wallet first bought a key (earliest first)
*/
export const CREATOR_HOLDER_SORT_FIELDS = ['key_balance', 'held_since'] as const;
export type CreatorHolderSortField = (typeof CREATOR_HOLDER_SORT_FIELDS)[number];

/**
* Validation schema for GET /creators/:id/holders query parameters.
*/
export const CreatorHoldersQuerySchema = z
.object({
limit: safeIntParam({
defaultValue: PUBLIC_OFFSET_PAGINATION_DEFAULTS.limit,
min: MIN_PAGE_SIZE,
max: MAX_PAGE_SIZE,
label: 'Limit',
}),
offset: safeIntParam({
defaultValue: PUBLIC_OFFSET_PAGINATION_DEFAULTS.offset,
min: 0,
max: Number.MAX_SAFE_INTEGER,
label: 'Offset',
}),
sort: z
.enum(CREATOR_HOLDER_SORT_FIELDS)
.optional()
.default('key_balance'),
})
.strict();

export type CreatorHoldersQueryType = z.infer<typeof CreatorHoldersQuerySchema>;
Loading
Loading