diff --git a/src/modules/creator/creator-profile.service.ts b/src/modules/creator/creator-profile.service.ts index 96977fd..331f889 100644 --- a/src/modules/creator/creator-profile.service.ts +++ b/src/modules/creator/creator-profile.service.ts @@ -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), @@ -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( @@ -58,6 +58,19 @@ function buildCreatorDetailCacheMissContext(creatorId: string) { }; } +export async function creatorProfileExists( + creatorId: string +): Promise { + 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. * @@ -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 { @@ -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) diff --git a/src/modules/creators/creator-detail-not-found.integration.test.ts b/src/modules/creators/creator-detail-not-found.integration.test.ts new file mode 100644 index 0000000..6770750 --- /dev/null +++ b/src/modules/creators/creator-detail-not-found.integration.test.ts @@ -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); + }); +}); diff --git a/src/modules/creators/creators.controllers.ts b/src/modules/creators/creators.controllers.ts index e7f5b11..d116973 100644 --- a/src/modules/creators/creators.controllers.ts +++ b/src/modules/creators/creators.controllers.ts @@ -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'; @@ -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 @@ -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; @@ -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 @@ -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 @@ -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); + } +}; diff --git a/src/modules/creators/creators.routes.ts b/src/modules/creators/creators.routes.ts index 6fde2be..2d503c4 100644 --- a/src/modules/creators/creators.routes.ts +++ b/src/modules/creators/creators.routes.ts @@ -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'; @@ -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 / @@ -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 @@ -61,7 +69,9 @@ 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 @@ -69,4 +79,24 @@ creatorsRouter.all('/:id/holders', (_req, res) => { res.set('Allow', 'GET').sendStatus(405); }); -export default creatorsRouter; \ No newline at end of file +/** + * 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; diff --git a/src/utils/test/auth-request.utils.test.ts b/src/utils/test/auth-request.utils.test.ts new file mode 100644 index 0000000..717506e --- /dev/null +++ b/src/utils/test/auth-request.utils.test.ts @@ -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 }); + }); +}); diff --git a/src/utils/test/auth-request.utils.ts b/src/utils/test/auth-request.utils.ts new file mode 100644 index 0000000..5b00f57 --- /dev/null +++ b/src/utils/test/auth-request.utils.ts @@ -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 { + '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; + const headers = buildAuthHeaders(body, walletKeypair, options.timestamp); + + return request[normalizedMethod](path).set(headers).send(body); +}