import { describe, it, expect } from 'vitest'; import { normalizeProfileStyle, DEFAULT_PROFILE_STYLE, PROFILE_LIMITS, NAME_FONTS, NAME_EFFECTS, AVATAR_DECORATIONS, PROFILE_EFFECTS, NAMEPLATES, } from '@backspace/shared/src/profile'; import { computeBadges, detectLinkIcon } from './profileStyle'; import type { User } from '@backspace/shared'; describe('normalizeProfileStyle', () => { it('returns defaults for garbage', () => { expect(normalizeProfileStyle(null)).toEqual(DEFAULT_PROFILE_STYLE); expect(normalizeProfileStyle('nope')).toEqual(DEFAULT_PROFILE_STYLE); expect(normalizeProfileStyle({ name: 'x', links: 'y', widgets: 3 })).toEqual(DEFAULT_PROFILE_STYLE); }); it('falls back on unknown catalog ids instead of throwing', () => { // Ids that are not in any catalog — deliberately absurd, so growing a // catalog never quietly turns this case into a passing no-op. const s = normalizeProfileStyle({ name: { font: 'wingdings', effect: 'interpretive-dance' }, decoration: 'lasers', effect: 'volcano', nameplate: 'zebra' }); expect(s.name.font).toBe('default'); expect(s.name.effect).toBe('solid'); expect(s.decoration).toBe('none'); expect(s.effect).toBe('none'); expect(s.nameplate).toBe('none'); }); it('rejects bad colours, lowercases good ones', () => { const s = normalizeProfileStyle({ name: { colors: ['#ABCDEF', 'red'] } }); expect(s.name.colors).toEqual(['#abcdef', DEFAULT_PROFILE_STYLE.name.colors[1]]); }); it('bounds every string', () => { const s = normalizeProfileStyle({ pronouns: 'x'.repeat(500), widgets: { quote: { text: 'q'.repeat(999), author: 'a'.repeat(999) } } }); expect(s.pronouns).toHaveLength(PROFILE_LIMITS.pronouns); expect(s.widgets.quote?.text).toHaveLength(PROFILE_LIMITS.quoteText); expect(s.widgets.quote?.author).toHaveLength(PROFILE_LIMITS.quoteAuthor); }); it('tag: uppercase alphanumerics, 2–4 chars, one emoji grapheme', () => { expect(normalizeProfileStyle({ tag: { text: 'strt!', emoji: '🔥🔥', color: '#ff0000' } }).tag).toEqual({ text: 'STRT', emoji: '🔥', color: '#ff0000' }); expect(normalizeProfileStyle({ tag: { text: 'a' } }).tag).toBeNull(); expect(normalizeProfileStyle({ tag: { text: 'abcdefg' } }).tag?.text).toBe('ABCD'); }); it('links: https only, capped, label defaults to host', () => { const s = normalizeProfileStyle({ links: [ { label: '', url: 'https://www.twitch.tv/syncwrld' }, { label: 'bad', url: 'http://insecure.example' }, { label: 'js', url: 'javascript:alert(1)' }, ...Array.from({ length: 10 }, (_, i) => ({ label: `l${i}`, url: `https://x.com/${i}` })), ] }); expect(s.links).toHaveLength(PROFILE_LIMITS.links); expect(s.links[0]).toEqual({ label: 'twitch.tv', url: 'https://www.twitch.tv/syncwrld' }); expect(s.links.some((l) => l.url.startsWith('http:') || l.url.startsWith('javascript'))).toBe(false); }); it('game covers: only Steam CDN hosts survive', () => { const s = normalizeProfileStyle({ widgets: { favoriteGame: { name: 'CS2', appId: 730, cover: 'https://cdn.cloudflare.steamstatic.com/steam/apps/730/library_600x900.jpg' }, rotation: [{ name: 'Sneaky', cover: 'https://evil.example/track.png' }], } }); expect(s.widgets.favoriteGame?.cover).toContain('steamstatic'); expect(s.widgets.rotation[0]?.cover).toBeNull(); expect(s.widgets.rotation[0]?.name).toBe('Sneaky'); }); it('accepts every id the catalogs declare', () => { // Guards the pairing between the catalogs and the normaliser: adding an id // to one without the other would show up here rather than as a silent // fallback in the UI. for (const font of NAME_FONTS) expect(normalizeProfileStyle({ name: { font } }).name.font).toBe(font); for (const effect of NAME_EFFECTS) expect(normalizeProfileStyle({ name: { effect } }).name.effect).toBe(effect); for (const decoration of AVATAR_DECORATIONS) expect(normalizeProfileStyle({ decoration }).decoration).toBe(decoration); for (const effect of PROFILE_EFFECTS) expect(normalizeProfileStyle({ effect }).effect).toBe(effect); for (const nameplate of NAMEPLATES) expect(normalizeProfileStyle({ nameplate }).nameplate).toBe(nameplate); }); it('is idempotent', () => { const once = normalizeProfileStyle({ name: { font: 'comic', effect: 'neon', colors: ['#111111', '#222222'] }, pronouns: 'ele/dele' }); expect(normalizeProfileStyle(once)).toEqual(once); }); }); describe('detectLinkIcon', () => { it('maps known hosts and subdomains', () => { expect(detectLinkIcon('https://www.twitch.tv/x')).toBe('twitch'); expect(detectLinkIcon('https://youtu.be/abc')).toBe('youtube'); expect(detectLinkIcon('https://open.spotify.com/user/x')).toBe('spotify'); expect(detectLinkIcon('https://steamcommunity.com/id/x')).toBe('steam'); expect(detectLinkIcon('https://twitter.com/x')).toBe('x'); expect(detectLinkIcon('https://streethosting.com.br')).toBe('link'); expect(detectLinkIcon('not a url')).toBe('link'); }); }); describe('computeBadges', () => { const now = Date.UTC(2026, 8, 2); const DAY = 86_400_000; const base = { id: 'u1', username: 'u', displayName: null, avatar: null, banner: null, accentColor: null, avatarColor: null, bio: null, status: 'online', customStatus: null, isAdmin: false, homeInstance: null, homeUserId: null, replicatedInstances: [] } as unknown as User; it('derives owner, founder, tenure and stylist from facts already present', () => { const space = { ownerId: 'u1', createdAt: now - 400 * DAY }; const owner = computeBadges({ ...base, createdAt: now - 400 * DAY }, { space, now }); expect(owner).toEqual(['owner', 'veteran']); const founder = computeBadges({ ...base, id: 'u2', createdAt: now - 200 * DAY }, { space, member: { joinedAt: space.createdAt + 5 * DAY }, now }); expect(founder).toEqual(['founder', 'og']); const late = computeBadges({ ...base, id: 'u3', createdAt: now - 10 * DAY }, { space, member: { joinedAt: space.createdAt + 90 * DAY }, now }); expect(late).toEqual([]); const styled = computeBadges({ ...base, id: 'u4', createdAt: now, profileStyle: normalizeProfileStyle({ decoration: 'flames' }) }, { now }); expect(styled).toEqual(['stylist']); }); it('admin comes first', () => { expect(computeBadges({ ...base, isAdmin: true, createdAt: now }, { now })).toEqual(['admin']); }); });