Skip to content
Open
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
48 changes: 48 additions & 0 deletions src/modules/creator/creator-profile.service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,11 @@ describe('getCreatorProfile', () => {
avatarUrl: null,
createdAt: null,
updatedAt: null,
perks: [],
links: [],
currentPrice: null,
price24hAgo: null,
priceChange24h: null,
metadata: {
source: 'placeholder',
isProfileComplete: false,
Expand Down Expand Up @@ -146,4 +150,48 @@ describe('upsertCreatorProfile', () => {
})
);
});

it('truncates multi-byte payload fields to byte limits before persistence', async () => {
const result = await upsertCreatorProfile('creator-5', {
displayName: '你'.repeat(40),
bio: '界'.repeat(400),
links: [
{
label: '名'.repeat(20),
url: 'https://example.com',
},
],
perks: [
{
title: '礼'.repeat(60),
description: '品'.repeat(250),
},
],
} as never);

expect(
Buffer.byteLength(result.acceptedProfile.displayName ?? '', 'utf8')
).toBeLessThanOrEqual(80);
expect(
Buffer.byteLength(result.acceptedProfile.bio ?? '', 'utf8')
).toBeLessThanOrEqual(1000);
expect(
Buffer.byteLength(
result.acceptedProfile.links?.[0]?.label ?? '',
'utf8'
)
).toBeLessThanOrEqual(40);
expect(
Buffer.byteLength(
result.acceptedProfile.perks?.[0]?.title ?? '',
'utf8'
)
).toBeLessThanOrEqual(100);
expect(
Buffer.byteLength(
result.acceptedProfile.perks?.[0]?.description ?? '',
'utf8'
)
).toBeLessThanOrEqual(500);
});
});
24 changes: 15 additions & 9 deletions src/modules/creator/creator-profile.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { CREATOR_DETAIL_DEFAULT_SELECT } from '../../constants/creator-detail-in
import { formatIsoTimestamp } from '../../utils/iso-timestamp.utils';
import { compute24hPriceChange } from '../../utils/price.utils';
import { normalizeSocialLinkUrl } from './creator-social-link-url.utils';
import { truncateString } from '../../utils/string-truncate.utils';
import { truncateToBytes } from '../../utils/string-truncate.utils';

const CREATOR_PROFILE_LIMITS = {
displayName: 80,
Expand All @@ -25,9 +25,9 @@ function normalizeProfileLinks(
return links;
}

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

return perks.map((perk) => ({
return perks.map(perk => ({
...perk,
title: truncateString(perk.title, CREATOR_PROFILE_LIMITS.perkTitle),
description: truncateString(
title: truncateToBytes(perk.title, CREATOR_PROFILE_LIMITS.perkTitle),
description: truncateToBytes(
perk.description,
CREATOR_PROFILE_LIMITS.perkDescription
),
Expand Down Expand Up @@ -110,7 +110,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,10 +151,13 @@ export async function upsertCreatorProfile(
const normalizedPayload: UpsertCreatorProfileBody = {
...payload,
displayName: payload.displayName
? truncateString(payload.displayName, CREATOR_PROFILE_LIMITS.displayName)
? truncateToBytes(
payload.displayName,
CREATOR_PROFILE_LIMITS.displayName
)
: payload.displayName,
bio: payload.bio
? truncateString(payload.bio, CREATOR_PROFILE_LIMITS.bio)
? truncateToBytes(payload.bio, CREATOR_PROFILE_LIMITS.bio)
: payload.bio,
links: normalizeProfileLinks(payload.links),
perks: normalizeProfilePerks(payload.perks),
Expand Down
39 changes: 38 additions & 1 deletion src/utils/string-truncate.utils.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { truncateString } from './string-truncate.utils';
import { truncateString, truncateToBytes } from './string-truncate.utils';

describe('truncateString', () => {
it('returns the original string when it is below the limit', () => {
Expand All @@ -19,3 +19,40 @@ describe('truncateString', () => {
);
});
});

describe('truncateToBytes', () => {
it('returns ASCII strings unchanged when they fit within the byte limit', () => {
expect(truncateToBytes('hello', 10)).toBe('hello');
});

it('truncates ASCII strings at the byte limit', () => {
const result = truncateToBytes('hello world', 5);

expect(result).toBe('hello');
expect(Buffer.byteLength(result, 'utf8')).toBeLessThanOrEqual(5);
});

it('does not split multi-byte characters', () => {
const result = truncateToBytes('你好世界', 7);

expect(result).toBe('你好');
expect(Buffer.byteLength(result, 'utf8')).toBeLessThanOrEqual(7);
});

it('returns empty strings unchanged', () => {
expect(truncateToBytes('', 5)).toBe('');
});

it('returns an empty string when the first character exceeds the byte limit', () => {
const result = truncateToBytes('你', 2);

expect(result).toBe('');
expect(Buffer.byteLength(result, 'utf8')).toBeLessThanOrEqual(2);
});

it('rejects negative byte limits', () => {
expect(() => truncateToBytes('hello', -1)).toThrow(
'maxBytes must be a non-negative finite number'
);
});
});
32 changes: 32 additions & 0 deletions src/utils/string-truncate.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,35 @@ export function truncateString(value: string, maxLength: number): string {

return value.slice(0, maxLength);
}

/**
* Truncate a string so its UTF-8 encoded byte length does not exceed maxBytes.
*
* Iterating by code point keeps surrogate pairs and multi-byte characters intact
* while still enforcing byte-oriented database column limits.
*/
export function truncateToBytes(value: string, maxBytes: number): string {
if (!Number.isFinite(maxBytes) || maxBytes < 0) {
throw new RangeError('maxBytes must be a non-negative finite number');
}

if (Buffer.byteLength(value, 'utf8') <= maxBytes) {
return value;
}

let byteLength = 0;
let result = '';

for (const char of value) {
const charBytes = Buffer.byteLength(char, 'utf8');

if (byteLength + charBytes > maxBytes) {
break;
}

result += char;
byteLength += charBytes;
}

return result;
}
Loading