Skip to content

Commit ffddd15

Browse files
Merge pull request #2 from chiscookeke11/codex/add-integration-test-for-404-error-response
Return 404 for missing creator detail (GET /api/v1/creators/:id)
2 parents 7a5f6fa + 5f898cf commit ffddd15

4 files changed

Lines changed: 136 additions & 17 deletions

File tree

src/modules/creator/creator-profile.service.ts

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ function normalizeProfileLinks(
2525
return links;
2626
}
2727

28-
return links.map((link) => ({
28+
return links.map(link => ({
2929
...link,
3030
label: truncateString(link.label, CREATOR_PROFILE_LIMITS.linkLabel),
3131
url: normalizeSocialLinkUrl(link.url),
@@ -39,7 +39,7 @@ function normalizeProfilePerks(
3939
return perks;
4040
}
4141

42-
return perks.map((perk) => ({
42+
return perks.map(perk => ({
4343
...perk,
4444
title: truncateString(perk.title, CREATOR_PROFILE_LIMITS.perkTitle),
4545
description: truncateString(
@@ -58,6 +58,19 @@ function buildCreatorDetailCacheMissContext(creatorId: string) {
5858
};
5959
}
6060

61+
export async function creatorProfileExists(
62+
creatorId: string
63+
): Promise<boolean> {
64+
const profile = await prisma.creatorProfile.findFirst({
65+
where: {
66+
OR: [{ id: creatorId }, { handle: creatorId }],
67+
},
68+
select: { id: true },
69+
});
70+
71+
return profile !== null;
72+
}
73+
6174
/**
6275
* Reads a creator profile from the database.
6376
*
@@ -110,7 +123,10 @@ export async function getCreatorProfile(
110123

111124
let priceChange24h: number | null = null;
112125
if (snapshot) {
113-
priceChange24h = compute24hPriceChange(snapshot.currentPrice, snapshot.price24hAgo);
126+
priceChange24h = compute24hPriceChange(
127+
snapshot.currentPrice,
128+
snapshot.price24hAgo
129+
);
114130
}
115131

116132
return {
@@ -148,7 +164,10 @@ export async function upsertCreatorProfile(
148164
const normalizedPayload: UpsertCreatorProfileBody = {
149165
...payload,
150166
displayName: payload.displayName
151-
? truncateString(payload.displayName, CREATOR_PROFILE_LIMITS.displayName)
167+
? truncateString(
168+
payload.displayName,
169+
CREATOR_PROFILE_LIMITS.displayName
170+
)
152171
: payload.displayName,
153172
bio: payload.bio
154173
? truncateString(payload.bio, CREATOR_PROFILE_LIMITS.bio)
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import supertest from 'supertest';
2+
import { ErrorCode } from '../../constants/error.constants';
3+
4+
jest.mock('../../utils/prisma.utils', () => ({
5+
prisma: {
6+
$disconnect: jest.fn(),
7+
},
8+
}));
9+
10+
jest.mock('../creator/creator-profile.service', () => ({
11+
creatorProfileExists: jest.fn().mockResolvedValue(false),
12+
getCreatorProfile: jest.fn(),
13+
}));
14+
15+
describe('GET /api/v1/creators/:id — not found', () => {
16+
it('returns 404 with the standard error shape for a non-existent creator', async () => {
17+
const { default: app } = await import('../../app');
18+
19+
const res = await supertest(app).get(
20+
'/api/v1/creators/non-existent-creator-for-404-test'
21+
);
22+
23+
expect(res.status).toBe(404);
24+
expect(res.body).toMatchObject({
25+
success: false,
26+
error: {
27+
code: ErrorCode.NOT_FOUND,
28+
message: expect.any(String),
29+
},
30+
});
31+
expect(res.body.error.code).toBe('NOT_FOUND');
32+
expect(res.body.error.message).toMatch(/creator.*not found/i);
33+
});
34+
});

src/modules/creators/creators.controllers.ts

Lines changed: 44 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { mapPublicCreatorStats } from './creators.stats';
99
import {
1010
sendSuccess,
1111
sendValidationError,
12+
sendNotFound,
1213
} from '../../utils/api-response.utils';
1314
import { attachTimestampHeader } from '../../utils/timestamp-headers.utils';
1415
import { parsePublicQuery } from '../../utils/public-query-parse.utils';
@@ -21,6 +22,10 @@ import {
2122
type FilterParseErrorCategory,
2223
} from '../../utils/filter-parse-metrics.utils';
2324
import { parseCreatorId } from '../../utils/creator-id.utils';
25+
import {
26+
creatorProfileExists,
27+
getCreatorProfile,
28+
} from '../creator/creator-profile.service';
2429

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

3742
// Validate query parameters
38-
const parsed = parsePublicQuery(
39-
CreatorListQuerySchema,
40-
ctx.query,
41-
{ debugContext: 'creator-list-query' }
42-
);
43+
const parsed = parsePublicQuery(CreatorListQuerySchema, ctx.query, {
44+
debugContext: 'creator-list-query',
45+
});
4346
if (!parsed.ok) {
4447
// Increment filter parse error counter
4548
const category = categorizeParseError(parsed.details);
4649
incrementFilterParseError('/api/v1/creators', category);
47-
return sendValidationError(res, 'Invalid query parameters', parsed.details);
50+
return sendValidationError(
51+
res,
52+
'Invalid query parameters',
53+
parsed.details
54+
);
4855
}
4956
const validatedQuery = parsed.data;
5057

@@ -87,7 +94,12 @@ function categorizeParseError(
8794
details: Array<{ field: string; message: string }>
8895
): FilterParseErrorCategory {
8996
// Check for unknown key errors (strict mode violations)
90-
if (details.some(d => d.message.includes('unrecognized') || d.message.includes('unknown'))) {
97+
if (
98+
details.some(
99+
d =>
100+
d.message.includes('unrecognized') || d.message.includes('unknown')
101+
)
102+
) {
91103
return 'unknown_key';
92104
}
93105
// Default to invalid_value for type/range errors
@@ -103,7 +115,9 @@ function categorizeParseError(
103115
export const httpGetCreatorStats: AsyncController = async (req, res, next) => {
104116
try {
105117
const rawId = req.params.id;
106-
const _creatorId = parseCreatorId(Array.isArray(rawId) ? rawId[0] : rawId);
118+
const _creatorId = parseCreatorId(
119+
Array.isArray(rawId) ? rawId[0] : rawId
120+
);
107121

108122
// TODO: Fetch actual creator metrics from database/service using _creatorId
109123
// For now, return placeholder data
@@ -123,3 +137,25 @@ export const httpGetCreatorStats: AsyncController = async (req, res, next) => {
123137
next(error);
124138
}
125139
};
140+
141+
/**
142+
* Controller for GET /api/v1/creators/:id
143+
*
144+
* Returns public profile details for a specific creator.
145+
*/
146+
export const httpGetCreator: AsyncController = async (req, res, next) => {
147+
try {
148+
const rawId = req.params.id;
149+
const creatorId = Array.isArray(rawId) ? rawId[0] : rawId;
150+
151+
if (!(await creatorProfileExists(creatorId))) {
152+
return sendNotFound(res, 'Creator');
153+
}
154+
155+
const profile = await getCreatorProfile(creatorId);
156+
attachTimestampHeader(res);
157+
sendSuccess(res, profile, 200, 'Creator retrieved successfully');
158+
} catch (error) {
159+
next(error);
160+
}
161+
};

src/modules/creators/creators.routes.ts

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
import { Router } from 'express';
2-
import { httpListCreators, httpGetCreatorStats } from './creators.controllers';
2+
import {
3+
httpListCreators,
4+
httpGetCreator,
5+
httpGetCreatorStats,
6+
} from './creators.controllers';
37
import { httpGetCreatorHolders } from './creator-holders.controller';
48
import { cacheControl } from '../../middlewares/cache-control.middleware';
59
import { CREATOR_PUBLIC_ROUTE_CACHE_PRESETS } from '../../constants/creator-public-cache.constants';
@@ -24,7 +28,9 @@ creatorsRouter.use(normalizeTrailingSlash);
2428
creatorsRouter.get(
2529
'/',
2630
createCreatorReadMetricsMiddleware('list'),
27-
cacheControl(CREATOR_PUBLIC_ROUTE_CACHE_PRESETS[CREATOR_PUBLIC_ROUTE_NAMES.LIST]),
31+
cacheControl(
32+
CREATOR_PUBLIC_ROUTE_CACHE_PRESETS[CREATOR_PUBLIC_ROUTE_NAMES.LIST]
33+
),
2834
httpListCreators
2935
);
3036
// 405 handler for /
@@ -42,7 +48,9 @@ creatorsRouter.get(
4248
'/:id/stats',
4349
validateCreatorParam('id'),
4450
createCreatorReadMetricsMiddleware('detail'),
45-
cacheControl(CREATOR_PUBLIC_ROUTE_CACHE_PRESETS[CREATOR_PUBLIC_ROUTE_NAMES.GET_STATS]),
51+
cacheControl(
52+
CREATOR_PUBLIC_ROUTE_CACHE_PRESETS[CREATOR_PUBLIC_ROUTE_NAMES.GET_STATS]
53+
),
4654
httpGetCreatorStats
4755
);
4856
// 405 handler for /:id/stats
@@ -61,12 +69,34 @@ creatorsRouter.get(
6169
'/:id/holders',
6270
validateCreatorParam('id'),
6371
createCreatorReadMetricsMiddleware('holders'),
64-
cacheControl(CREATOR_PUBLIC_ROUTE_CACHE_PRESETS[CREATOR_PUBLIC_ROUTE_NAMES.GET_HOLDERS]),
72+
cacheControl(
73+
CREATOR_PUBLIC_ROUTE_CACHE_PRESETS[CREATOR_PUBLIC_ROUTE_NAMES.GET_HOLDERS]
74+
),
6575
httpGetCreatorHolders
6676
);
6777
// 405 handler for /:id/holders
6878
creatorsRouter.all('/:id/holders', (_req, res) => {
6979
res.set('Allow', 'GET').sendStatus(405);
7080
});
7181

72-
export default creatorsRouter;
82+
/**
83+
* GET /api/v1/creators/:id
84+
*
85+
* Get public details for a specific creator.
86+
* Public endpoint with 5-minute cache.
87+
*/
88+
creatorsRouter.get(
89+
'/:id',
90+
validateCreatorParam('id'),
91+
createCreatorReadMetricsMiddleware('detail'),
92+
cacheControl(
93+
CREATOR_PUBLIC_ROUTE_CACHE_PRESETS[CREATOR_PUBLIC_ROUTE_NAMES.GET_PROFILE]
94+
),
95+
httpGetCreator
96+
);
97+
// 405 handler for /:id
98+
creatorsRouter.all('/:id', (_req, res) => {
99+
res.set('Allow', 'GET').sendStatus(405);
100+
});
101+
102+
export default creatorsRouter;

0 commit comments

Comments
 (0)