diff --git a/src/modules/creator/creator-profile.service.test.ts b/src/modules/creator/creator-profile.service.test.ts index c2ac918..d37afee 100644 --- a/src/modules/creator/creator-profile.service.test.ts +++ b/src/modules/creator/creator-profile.service.test.ts @@ -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, @@ -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); + }); }); diff --git a/src/modules/creator/creator-profile.service.ts b/src/modules/creator/creator-profile.service.ts index 96977fd..edef783 100644 --- a/src/modules/creator/creator-profile.service.ts +++ b/src/modules/creator/creator-profile.service.ts @@ -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, @@ -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), })); } @@ -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 ), @@ -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 { @@ -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), diff --git a/src/utils/string-truncate.utils.test.ts b/src/utils/string-truncate.utils.test.ts index c74885b..4ece362 100644 --- a/src/utils/string-truncate.utils.test.ts +++ b/src/utils/string-truncate.utils.test.ts @@ -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', () => { @@ -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' + ); + }); +}); diff --git a/src/utils/string-truncate.utils.ts b/src/utils/string-truncate.utils.ts index f19e69d..b7a44df 100644 --- a/src/utils/string-truncate.utils.ts +++ b/src/utils/string-truncate.utils.ts @@ -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; +}