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
27 changes: 23 additions & 4 deletions src/modules/creator/creator-profile.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ function normalizeProfileLinks(
return links;
}

return links.map((link) => ({
return links.map(link => ({
...link,
label: truncateString(link.label, CREATOR_PROFILE_LIMITS.linkLabel),
url: normalizeSocialLinkUrl(link.url),
Expand All @@ -39,7 +39,7 @@ function normalizeProfilePerks(
return perks;
}

return perks.map((perk) => ({
return perks.map(perk => ({
...perk,
title: truncateString(perk.title, CREATOR_PROFILE_LIMITS.perkTitle),
description: truncateString(
Expand All @@ -58,6 +58,19 @@ function buildCreatorDetailCacheMissContext(creatorId: string) {
};
}

export async function creatorProfileExists(
creatorId: string
): Promise<boolean> {
const profile = await prisma.creatorProfile.findFirst({
where: {
OR: [{ id: creatorId }, { handle: creatorId }],
},
select: { id: true },
});

return profile !== null;
}

/**
* Reads a creator profile from the database.
*
Expand Down Expand Up @@ -110,7 +123,10 @@ export async function getCreatorProfile(

let priceChange24h: number | null = null;
if (snapshot) {
priceChange24h = compute24hPriceChange(snapshot.currentPrice, snapshot.price24hAgo);
priceChange24h = compute24hPriceChange(
snapshot.currentPrice,
snapshot.price24hAgo
);
}

return {
Expand Down Expand Up @@ -148,7 +164,10 @@ export async function upsertCreatorProfile(
const normalizedPayload: UpsertCreatorProfileBody = {
...payload,
displayName: payload.displayName
? truncateString(payload.displayName, CREATOR_PROFILE_LIMITS.displayName)
? truncateString(
payload.displayName,
CREATOR_PROFILE_LIMITS.displayName
)
: payload.displayName,
bio: payload.bio
? truncateString(payload.bio, CREATOR_PROFILE_LIMITS.bio)
Expand Down
34 changes: 34 additions & 0 deletions src/modules/creators/creator-detail-not-found.integration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import supertest from 'supertest';
import { ErrorCode } from '../../constants/error.constants';

jest.mock('../../utils/prisma.utils', () => ({
prisma: {
$disconnect: jest.fn(),
},
}));

jest.mock('../creator/creator-profile.service', () => ({
creatorProfileExists: jest.fn().mockResolvedValue(false),
getCreatorProfile: jest.fn(),
}));

describe('GET /api/v1/creators/:id — not found', () => {
it('returns 404 with the standard error shape for a non-existent creator', async () => {
const { default: app } = await import('../../app');

const res = await supertest(app).get(
'/api/v1/creators/non-existent-creator-for-404-test'
);

expect(res.status).toBe(404);
expect(res.body).toMatchObject({
success: false,
error: {
code: ErrorCode.NOT_FOUND,
message: expect.any(String),
},
});
expect(res.body.error.code).toBe('NOT_FOUND');
expect(res.body.error.message).toMatch(/creator.*not found/i);
});
});
52 changes: 44 additions & 8 deletions src/modules/creators/creators.controllers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { mapPublicCreatorStats } from './creators.stats';
import {
sendSuccess,
sendValidationError,
sendNotFound,
} from '../../utils/api-response.utils';
import { attachTimestampHeader } from '../../utils/timestamp-headers.utils';
import { parsePublicQuery } from '../../utils/public-query-parse.utils';
Expand All @@ -21,6 +22,10 @@ import {
type FilterParseErrorCategory,
} from '../../utils/filter-parse-metrics.utils';
import { parseCreatorId } from '../../utils/creator-id.utils';
import {
creatorProfileExists,
getCreatorProfile,
} from '../creator/creator-profile.service';

/**
* Controller for GET /api/v1/creators
Expand All @@ -35,16 +40,18 @@ export const httpListCreators: AsyncController = async (req, res, next) => {
warnIfUnrecognizedCreatorListSort(ctx.query, req.requestId);

// Validate query parameters
const parsed = parsePublicQuery(
CreatorListQuerySchema,
ctx.query,
{ debugContext: 'creator-list-query' }
);
const parsed = parsePublicQuery(CreatorListQuerySchema, ctx.query, {
debugContext: 'creator-list-query',
});
if (!parsed.ok) {
// Increment filter parse error counter
const category = categorizeParseError(parsed.details);
incrementFilterParseError('/api/v1/creators', category);
return sendValidationError(res, 'Invalid query parameters', parsed.details);
return sendValidationError(
res,
'Invalid query parameters',
parsed.details
);
}
const validatedQuery = parsed.data;

Expand Down Expand Up @@ -87,7 +94,12 @@ function categorizeParseError(
details: Array<{ field: string; message: string }>
): FilterParseErrorCategory {
// Check for unknown key errors (strict mode violations)
if (details.some(d => d.message.includes('unrecognized') || d.message.includes('unknown'))) {
if (
details.some(
d =>
d.message.includes('unrecognized') || d.message.includes('unknown')
)
) {
return 'unknown_key';
}
// Default to invalid_value for type/range errors
Expand All @@ -103,7 +115,9 @@ function categorizeParseError(
export const httpGetCreatorStats: AsyncController = async (req, res, next) => {
try {
const rawId = req.params.id;
const _creatorId = parseCreatorId(Array.isArray(rawId) ? rawId[0] : rawId);
const _creatorId = parseCreatorId(
Array.isArray(rawId) ? rawId[0] : rawId
);

// TODO: Fetch actual creator metrics from database/service using _creatorId
// For now, return placeholder data
Expand All @@ -123,3 +137,25 @@ export const httpGetCreatorStats: AsyncController = async (req, res, next) => {
next(error);
}
};

/**
* Controller for GET /api/v1/creators/:id
*
* Returns public profile details for a specific creator.
*/
export const httpGetCreator: AsyncController = async (req, res, next) => {
try {
const rawId = req.params.id;
const creatorId = Array.isArray(rawId) ? rawId[0] : rawId;

if (!(await creatorProfileExists(creatorId))) {
return sendNotFound(res, 'Creator');
}

const profile = await getCreatorProfile(creatorId);
attachTimestampHeader(res);
sendSuccess(res, profile, 200, 'Creator retrieved successfully');
} catch (error) {
next(error);
}
};
40 changes: 35 additions & 5 deletions src/modules/creators/creators.routes.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { Router } from 'express';
import { httpListCreators, httpGetCreatorStats } from './creators.controllers';
import {
httpListCreators,
httpGetCreator,
httpGetCreatorStats,
} from './creators.controllers';
import { httpGetCreatorHolders } from './creator-holders.controller';
import { cacheControl } from '../../middlewares/cache-control.middleware';
import { CREATOR_PUBLIC_ROUTE_CACHE_PRESETS } from '../../constants/creator-public-cache.constants';
Expand All @@ -24,7 +28,9 @@ creatorsRouter.use(normalizeTrailingSlash);
creatorsRouter.get(
'/',
createCreatorReadMetricsMiddleware('list'),
cacheControl(CREATOR_PUBLIC_ROUTE_CACHE_PRESETS[CREATOR_PUBLIC_ROUTE_NAMES.LIST]),
cacheControl(
CREATOR_PUBLIC_ROUTE_CACHE_PRESETS[CREATOR_PUBLIC_ROUTE_NAMES.LIST]
),
httpListCreators
);
// 405 handler for /
Expand All @@ -42,7 +48,9 @@ creatorsRouter.get(
'/:id/stats',
validateCreatorParam('id'),
createCreatorReadMetricsMiddleware('detail'),
cacheControl(CREATOR_PUBLIC_ROUTE_CACHE_PRESETS[CREATOR_PUBLIC_ROUTE_NAMES.GET_STATS]),
cacheControl(
CREATOR_PUBLIC_ROUTE_CACHE_PRESETS[CREATOR_PUBLIC_ROUTE_NAMES.GET_STATS]
),
httpGetCreatorStats
);
// 405 handler for /:id/stats
Expand All @@ -61,12 +69,34 @@ creatorsRouter.get(
'/:id/holders',
validateCreatorParam('id'),
createCreatorReadMetricsMiddleware('holders'),
cacheControl(CREATOR_PUBLIC_ROUTE_CACHE_PRESETS[CREATOR_PUBLIC_ROUTE_NAMES.GET_HOLDERS]),
cacheControl(
CREATOR_PUBLIC_ROUTE_CACHE_PRESETS[CREATOR_PUBLIC_ROUTE_NAMES.GET_HOLDERS]
),
httpGetCreatorHolders
);
// 405 handler for /:id/holders
creatorsRouter.all('/:id/holders', (_req, res) => {
res.set('Allow', 'GET').sendStatus(405);
});

export default creatorsRouter;
/**
* GET /api/v1/creators/:id
*
* Get public details for a specific creator.
* Public endpoint with 5-minute cache.
*/
creatorsRouter.get(
'/:id',
validateCreatorParam('id'),
createCreatorReadMetricsMiddleware('detail'),
cacheControl(
CREATOR_PUBLIC_ROUTE_CACHE_PRESETS[CREATOR_PUBLIC_ROUTE_NAMES.GET_PROFILE]
),
httpGetCreator
);
// 405 handler for /:id
creatorsRouter.all('/:id', (_req, res) => {
res.set('Allow', 'GET').sendStatus(405);
});

export default creatorsRouter;
68 changes: 68 additions & 0 deletions src/utils/test/auth-request.utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import express from 'express';
import { Keypair } from '@stellar/stellar-base';
import { buildAuthRequest } from './auth-request.utils';
import {
requireStellarSignature,
type StellarSignedRequest,
} from '../../middlewares/stellar-signature.middleware';

function createSignatureTestApp() {
const app = express();
app.use(express.json());

app.post(
'/protected',
requireStellarSignature(),
(req: StellarSignedRequest, res) => {
res.status(200).json({ signatureVerified: req.signatureVerified });
}
);

app.delete(
'/protected',
requireStellarSignature(),
(req: StellarSignedRequest, res) => {
res.status(200).json({ signatureVerified: req.signatureVerified });
}
);

return app;
}

describe('buildAuthRequest', () => {
it('builds a POST request whose auth headers pass Stellar signature verification', async () => {
const walletKeypair = Keypair.random();
const body = { displayName: 'Auth Helper POST' };

const res = await buildAuthRequest(
'POST',
'/protected',
body,
walletKeypair,
{
app: createSignatureTestApp(),
}
);

expect(res.status).toBe(200);
expect(res.body).toEqual({ signatureVerified: true });
});

it('builds a DELETE request whose auth headers pass Stellar signature verification', async () => {
const walletKeypair = Keypair.random();
const body = { reason: 'cleanup' };

const res = await buildAuthRequest(
'DELETE',
'/protected',
body,
walletKeypair,
{
app: createSignatureTestApp(),
}
);

expect(res.status).toBe(200);
expect(res.body).toEqual({ signatureVerified: true });
});
});
62 changes: 62 additions & 0 deletions src/utils/test/auth-request.utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import type { Express } from 'express';
import supertest from 'supertest';
import type TestAgent from 'supertest/lib/agent';
import type Test from 'supertest/lib/test';
import { Keypair } from '@stellar/stellar-base';
import { createHash } from 'crypto';

export type AuthRequestMethod = 'DELETE' | 'PATCH' | 'POST' | 'PUT';

export interface AuthRequestOptions {
timestamp?: string;
app?: Express;
}

export interface AuthRequestHeaders extends Record<string, string> {
'x-wallet-address': string;
'x-wallet-signature': string;
'x-timestamp': string;
}

function getDefaultApp(): Express {
// Lazily load the full application only when callers do not inject a test app.
// This keeps focused helper tests from compiling app-level dependencies.
return require('../../app').default as Express;
}

function buildCanonicalMessage(body: unknown, timestamp: string): Buffer {
const bodyJson = JSON.stringify(body);
const payload = `${bodyJson}${timestamp}`;
return createHash('sha256').update(payload, 'utf8').digest();
}

export function buildAuthHeaders(
body: string | object | undefined,
walletKeypair: Keypair,
timestamp = Date.now().toString()
): AuthRequestHeaders {
const message = buildCanonicalMessage(body, timestamp);
const signature = walletKeypair.sign(message).toString('base64');

return {
'x-wallet-address': walletKeypair.publicKey(),
'x-wallet-signature': signature,
'x-timestamp': timestamp,
};
}

export function buildAuthRequest(
method: AuthRequestMethod,
path: string,
body: string | object | undefined,
walletKeypair: Keypair,
options: AuthRequestOptions = {}
): Test {
const requestApp = options.app ?? getDefaultApp();
const request = supertest(requestApp) as TestAgent;
const normalizedMethod =
method.toLowerCase() as Lowercase<AuthRequestMethod>;
const headers = buildAuthHeaders(body, walletKeypair, options.timestamp);

return request[normalizedMethod](path).set(headers).send(body);
}
Loading