diff --git a/src/modules/event/screens/EventDetailScreen.tsx b/src/modules/event/screens/EventDetailScreen.tsx index 7dd1793..a0dbf2a 100644 --- a/src/modules/event/screens/EventDetailScreen.tsx +++ b/src/modules/event/screens/EventDetailScreen.tsx @@ -2,6 +2,7 @@ import { useLayoutEffect } from 'react'; import { ArrowLeft } from 'lucide-react'; import { Button, Avatar, EventCover, EventMeetingPoints, showToast, Text, type MeetingPointData } from '../../../shared/components/sketchy'; import { useFestipodData } from '../../../shared/context/FestipodDataContext'; +import { safeUserName, safeUserInitials, safeUserUsername } from '../../../shared/context/festipodDataBase'; import { eventPath, useNavigate, useParams, userPath } from '../../../app/router'; export function EventDetailScreen() { @@ -206,21 +207,26 @@ export function EventDetailScreen() {
Participants ({event.participantCount})
- {knownParticipants.map((p) => ( -
navigate(userPath(p.id))} - style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '10px 0', borderBottom: '1px solid #f5f5f5', cursor: 'pointer' }} - > - -
-
{p.name}
- {p.username && ( -
{p.username}
- )} + {knownParticipants.map((p) => { + const displayName = safeUserName(p); + const displayUsername = safeUserUsername(p); + const displayInitials = safeUserInitials(p); + return ( +
navigate(userPath(p.id))} + style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '10px 0', borderBottom: '1px solid #f5f5f5', cursor: 'pointer' }} + > + +
+
{displayName}
+ {displayUsername && ( +
{displayUsername}
+ )} +
-
- ))} + ); + })} {Array.from({ length: unknownCount }, (_, i) => (
- + - {friend.name.split(' ')[0]} + {safeUserName(friend).split(' ')[0]}
))} @@ -155,11 +156,11 @@ export function InviteScreen() { background: selected.has(friend.id) ? '#FFF7ED' : 'transparent', }} > - +
- {friend.name} - {friend.username && ( - {friend.username} + {safeUserName(friend)} + {safeUserUsername(friend) && ( + {safeUserUsername(friend)} )}
diff --git a/src/modules/event/screens/MeetingPointsScreen.tsx b/src/modules/event/screens/MeetingPointsScreen.tsx index 8f1b622..5e687ac 100644 --- a/src/modules/event/screens/MeetingPointsScreen.tsx +++ b/src/modules/event/screens/MeetingPointsScreen.tsx @@ -2,6 +2,7 @@ import React, { useState } from 'react'; import { ArrowLeft } from 'lucide-react'; import { Button, Avatar, Input, showToast } from '../../../shared/components/sketchy'; import { useFestipodData } from '../../../shared/context/FestipodDataContext'; +import { safeUserName, safeUserInitials } from '../../../shared/context/festipodDataBase'; import { eventPath, useNavigate, useParams } from '../../../app/router'; export function MeetingPointsScreen() { @@ -85,8 +86,8 @@ export function MeetingPointsScreen() { if (!friend) return null; return (
- - {friend.name.split(' ')[0]} + + {safeUserName(friend).split(' ')[0]} removeInvited(id)} style={{ fontSize: 14, color: '#bbb', cursor: 'pointer' }}>×
); diff --git a/src/modules/event/screens/ParticipantsListScreen.tsx b/src/modules/event/screens/ParticipantsListScreen.tsx index 9fa34f7..aa572fc 100644 --- a/src/modules/event/screens/ParticipantsListScreen.tsx +++ b/src/modules/event/screens/ParticipantsListScreen.tsx @@ -1,6 +1,7 @@ import { ArrowLeft } from 'lucide-react'; import { Header, Avatar, Text, Input } from '../../../shared/components/sketchy'; import { useFestipodData } from '../../../shared/context/FestipodDataContext'; +import { safeUserName, safeUserInitials, safeUserUsername } from '../../../shared/context/festipodDataBase'; import { eventPath, useNavigate, useParams, userPath } from '../../../app/router'; const COLORS = ['#E8590C', '#2B6CB0', '#9C4DC7', '#38A169', '#D69E2E', '#E53E3E']; @@ -23,9 +24,9 @@ export function ParticipantsListScreen() { const rows = [ ...participants.map((p, i) => ({ key: p.id, - initials: p.initials, - name: p.name, - username: p.username, + initials: safeUserInitials(p), + name: safeUserName(p), + username: safeUserUsername(p), color: COLORS[i % COLORS.length] ?? '#888', known: true, })), diff --git a/src/modules/user/screens/ConnectScreen.tsx b/src/modules/user/screens/ConnectScreen.tsx index 89750c3..00dfb47 100644 --- a/src/modules/user/screens/ConnectScreen.tsx +++ b/src/modules/user/screens/ConnectScreen.tsx @@ -2,6 +2,7 @@ import React, { useEffect, useState } from 'react'; import { ArrowLeft } from 'lucide-react'; import { Header, Avatar, Text, Button, showToast } from '../../../shared/components/sketchy'; import { useFestipodData } from '../../../shared/context/FestipodDataContext'; +import { safeUserName, safeUserInitials } from '../../../shared/context/festipodDataBase'; import { useNavigate, useGoBack } from '../../../app/router'; type Mode = 'choice' | 'show' | 'scan'; @@ -96,10 +97,10 @@ export function ConnectScreen() { borderRadius: '50%', }} > - +
- {currentUser?.name} + {safeUserName(currentUser, '')} Montrez ce QR code pour permettre à l'autre personne de vous connecter. diff --git a/src/modules/user/screens/FriendsListScreen.tsx b/src/modules/user/screens/FriendsListScreen.tsx index 4a66434..476df5d 100644 --- a/src/modules/user/screens/FriendsListScreen.tsx +++ b/src/modules/user/screens/FriendsListScreen.tsx @@ -1,6 +1,7 @@ import { ArrowLeft } from 'lucide-react'; import { Header, Text, Avatar, Input, Button, BottomNav } from '../../../shared/components/sketchy'; import { useFestipodData } from '../../../shared/context/FestipodDataContext'; +import { safeUserName, safeUserInitials, safeUserUsername } from '../../../shared/context/festipodDataBase'; import { useNavigate, userPath } from '../../../app/router'; const COLORS = ['#2B6CB0', '#9C4DC7', '#38A169', '#D69E2E', '#E53E3E', '#E8590C']; @@ -35,11 +36,11 @@ export function FriendsListScreen() { borderBottom: '1px solid #f5f5f5', }} > - +
- {person.name} + {safeUserName(person)} - {person.username} + {safeUserUsername(person)} {person.eventsCount != null && ` · ${person.eventsCount} événements`}
diff --git a/src/modules/user/screens/ProfileScreen.tsx b/src/modules/user/screens/ProfileScreen.tsx index d5d76cc..ccf266c 100644 --- a/src/modules/user/screens/ProfileScreen.tsx +++ b/src/modules/user/screens/ProfileScreen.tsx @@ -1,5 +1,6 @@ import { Header, Avatar, Title, Text, Button, Card, Divider, BottomNav, Tag } from '../../../shared/components/sketchy'; import { useFestipodData } from '../../../shared/context/FestipodDataContext'; +import { safeUserName, safeUserInitials, safeUserUsername } from '../../../shared/context/festipodDataBase'; import { eventPath, useNavigate } from '../../../app/router'; export function ProfileScreen() { @@ -18,9 +19,9 @@ export function ProfileScreen() {
- - {user?.name} - {user?.username} + + {safeUserName(user, '')} + {safeUserUsername(user)}
diff --git a/src/modules/user/screens/ShareProfileScreen.tsx b/src/modules/user/screens/ShareProfileScreen.tsx index cee0432..2e9ad1e 100644 --- a/src/modules/user/screens/ShareProfileScreen.tsx +++ b/src/modules/user/screens/ShareProfileScreen.tsx @@ -1,13 +1,15 @@ import { ArrowLeft } from 'lucide-react'; import { Header, Text, Button, Card, Divider, Avatar } from '../../../shared/components/sketchy'; import { useFestipodData } from '../../../shared/context/FestipodDataContext'; +import { safeUserName, safeUserInitials, safeUserUsername } from '../../../shared/context/festipodDataBase'; import { useNavigate } from '../../../app/router'; export function ShareProfileScreen() { const navigate = useNavigate(); const { currentUser } = useFestipodData(); const user = currentUser; - const profileLink = `festipod.app/u/${(user?.username ?? '').replace('@', '')}`; + const shareHandle = safeUserUsername(user).replace('@', ''); + const profileLink = shareHandle ? `festipod.app/u/${shareHandle}` : ''; return (
@@ -47,11 +49,11 @@ export function ShareProfileScreen() { padding: 4, borderRadius: '50%', }}> - +
- {user?.name} + {safeUserName(user, '')} Scannez pour me retrouver sur Festipod diff --git a/src/modules/user/screens/UpdateProfileScreen.tsx b/src/modules/user/screens/UpdateProfileScreen.tsx index e68feec..9629023 100644 --- a/src/modules/user/screens/UpdateProfileScreen.tsx +++ b/src/modules/user/screens/UpdateProfileScreen.tsx @@ -1,6 +1,7 @@ import React, { useState } from 'react'; import { Header, Text, Input, Button, Avatar, showToast } from '../../../shared/components/sketchy'; import { useFestipodData } from '../../../shared/context/FestipodDataContext'; +import { safeUserInitials } from '../../../shared/context/festipodDataBase'; import { useNavigate } from '../../../app/router'; export function UpdateProfileScreen() { @@ -41,7 +42,7 @@ export function UpdateProfileScreen() {
- + diff --git a/src/modules/user/screens/UserProfileScreen.tsx b/src/modules/user/screens/UserProfileScreen.tsx index 1198eda..a6e19b0 100644 --- a/src/modules/user/screens/UserProfileScreen.tsx +++ b/src/modules/user/screens/UserProfileScreen.tsx @@ -1,6 +1,7 @@ import { ArrowLeft } from 'lucide-react'; import { Header, Avatar, Title, Text, Button, Card, Badge, Divider } from '../../../shared/components/sketchy'; import { useFestipodData } from '../../../shared/context/FestipodDataContext'; +import { safeUserName, safeUserInitials, safeUserUsername } from '../../../shared/context/festipodDataBase'; import { eventPath, useNavigate, useGoBack, useParams } from '../../../app/router'; export function UserProfileScreen() { @@ -30,9 +31,9 @@ export function UserProfileScreen() {
- - {viewedUser?.name} - {viewedUser?.username} + + {safeUserName(viewedUser, viewedUser?.id === currentUserId ? '' : undefined)} + {safeUserUsername(viewedUser)}
diff --git a/src/shared/context/festipodDataBase.test.ts b/src/shared/context/festipodDataBase.test.ts new file mode 100644 index 0000000..950cdd2 --- /dev/null +++ b/src/shared/context/festipodDataBase.test.ts @@ -0,0 +1,185 @@ +import { expect, test, describe } from 'bun:test'; +import { buildQueries, resolveParticipantUser, safeUserName, safeUserInitials, safeUserUsername } from './festipodDataBase'; +import type { FpEventData, FpUserData, FpParticipationData, FpMeetingPointData, FpFriendshipData } from '../data/types'; + +describe('resolveParticipantUser', () => { + const users: FpUserData[] = [ + { id: 'user-1', name: 'Marie Dupont', initials: 'MD', username: '@mariedupont' }, + { id: 'http://localhost:3000/alice', name: 'Alice', initials: 'A', username: '@alice' }, + ]; + + test('matches by exact profile id', () => { + expect(resolveParticipantUser('user-1', users)?.name).toBe('Marie Dupont'); + expect(resolveParticipantUser('http://localhost:3000/alice', users)?.name).toBe('Alice'); + }); + + test('matches the legacy urn:festipod:user: form on normalized username', () => { + expect(resolveParticipantUser('urn:festipod:user:mariedupont', users)?.name).toBe('Marie Dupont'); + }); + + test('returns undefined when the participant profile is not available', () => { + expect(resolveParticipantUser('http://localhost:3000/bob', users)).toBeUndefined(); + expect(resolveParticipantUser('urn:festipod:user:unknown', users)).toBeUndefined(); + }); +}); + +describe('getEventParticipants', () => { + const event: FpEventData = { + id: 'event-1', + title: 'Concert', + description: '', + date: '2026-10-10', + location: 'Lyon', + participantCount: 2, + }; + const users: FpUserData[] = [ + { id: 'user-1', name: 'Marie Dupont', initials: 'MD', username: '@mariedupont' }, + { id: 'user-2', name: 'Jean Durand', initials: 'JD', username: '@jeandurand' }, + ]; + const participations: FpParticipationData[] = [ + { id: 'p1', eventId: 'event-1', userId: 'user-1', isConfirmed: true }, + { id: 'p2', eventId: 'event-1', userId: 'http://localhost:3000/bob', isConfirmed: true }, + { id: 'p3', eventId: 'event-2', userId: 'user-2', isConfirmed: true }, + ]; + const queries = buildQueries( + [event], + users, + participations, + [] as FpMeetingPointData[], + [] as FpFriendshipData[], + 'user-1', + ); + + test('returns only participants whose profile resolved', () => { + const found = queries.getEventParticipants('event-1'); + expect(found.map(u => u.id)).toEqual(['user-1']); + }); + + test('never exposes an unresolved attendee id as a name', () => { + const found = queries.getEventParticipants('event-1'); + for (const u of found) { + expect(u.name).not.toMatch(/https?:\/\//); + expect(u.name).not.toMatch(/^\//); + expect(u.name).not.toMatch(/^urn:/); + expect(u.username).not.toMatch(/https?:\/\//); + } + }); +}); + +describe('safeUserName', () => { + const user = (name: string): FpUserData => ({ + id: 'u-1', + name, + initials: 'U', + username: '@user', + }); + + test('keeps a human-readable name', () => { + expect(safeUserName(user('Marie Dupont'))).toBe('Marie Dupont'); + }); + + test('replaces an empty name with a neutral placeholder', () => { + expect(safeUserName(user(''))).toBe('Participant'); + }); + + test('replaces a WebID/actor URI with a neutral placeholder', () => { + expect(safeUserName(user('http://localhost:3000/alice'))).toBe('Participant'); + expect(safeUserName(user('https://example.com/as/actor/bob'))).toBe('Participant'); + }); + + test('replaces an actor path with a neutral placeholder', () => { + expect(safeUserName(user('/as/actor/alice'))).toBe('Participant'); + expect(safeUserName(user('/profiles/alice'))).toBe('Participant'); + }); + + test('replaces a URN, DID, mailto or tel identifier with a neutral placeholder', () => { + expect(safeUserName(user('urn:festipod:user:alice'))).toBe('Participant'); + expect(safeUserName(user('did:ng:abc123'))).toBe('Participant'); + expect(safeUserName(user('mailto:alice@example.com'))).toBe('Participant'); + expect(safeUserName(user('tel:+33123456789'))).toBe('Participant'); + }); + + test('masks a resolved profile whose name happens to be a raw URI', () => { + const resolved: FpUserData = { + id: 'http://localhost:3000/alice', + name: 'http://localhost:3000/alice', + initials: 'HA', + username: '@alice', + }; + expect(safeUserName(resolved)).toBe('Participant'); + expect(safeUserInitials(resolved)).toBe('?'); + }); +}); + +describe('absent user and own-profile fallback', () => { + const user = (name: string): FpUserData => ({ id: 'u-1', name, initials: 'U', username: '@user' }); + + test('an absent user renders blank name and username, as before the sanitizers', () => { + expect(safeUserName(undefined)).toBe(''); + expect(safeUserName(undefined, '')).toBe(''); + expect(safeUserUsername(undefined)).toBe(''); + }); + + test('an empty name stays empty when the caller passes an empty fallback (own profile)', () => { + expect(safeUserName(user(''), '')).toBe(''); + expect(safeUserName(user(' '), '')).toBe(''); + }); + + test('an identifier-shaped name is masked even with an empty fallback', () => { + expect(safeUserName(user('http://localhost:3000/alice'), '')).toBe('Participant'); + }); + + test('a human name is unchanged whatever the fallback', () => { + expect(safeUserName(user('Marie Dupont'), '')).toBe('Marie Dupont'); + }); +}); + +describe('safeUserInitials', () => { + const user = (name: string, initials: string): FpUserData => ({ + id: 'u-1', + name, + initials, + username: '@user', + }); + + test('keeps human initials', () => { + expect(safeUserInitials(user('Marie Dupont', 'MD'))).toBe('MD'); + }); + + test('falls back to ? when the initials are missing or identifier-shaped', () => { + expect(safeUserInitials(user('Marie Dupont', ''))).toBe('?'); + expect(safeUserInitials(user('Marie Dupont', '/as/actor/bob'))).toBe('?'); + }); + + test('returns ? when the name is identifier-shaped (derived and explicit initials are indistinguishable)', () => { + expect(safeUserInitials(user('http://localhost:3000/alice', 'HT'))).toBe('?'); + expect(safeUserInitials(user('http://localhost:3000/alice', 'AA'))).toBe('?'); + expect(safeUserInitials(user('urn:festipod:user:bob', 'BB'))).toBe('?'); + }); + + test('returns ? for an absent user', () => { + expect(safeUserInitials(undefined)).toBe('?'); + }); +}); + +describe('safeUserUsername', () => { + const user = (username: string): FpUserData => ({ + id: 'u-1', + name: 'Nom', + initials: 'N', + username, + }); + + test('keeps a human handle', () => { + expect(safeUserUsername(user('@alice'))).toBe('@alice'); + }); + + test('drops an identifier-shaped username', () => { + expect(safeUserUsername(user('http://localhost:3000/alice'))).toBe(''); + expect(safeUserUsername(user('/as/actor/alice'))).toBe(''); + }); + + test('drops an empty username', () => { + expect(safeUserUsername(user(''))).toBe(''); + }); +}); diff --git a/src/shared/context/festipodDataBase.ts b/src/shared/context/festipodDataBase.ts index eb8c30e..977a4b2 100644 --- a/src/shared/context/festipodDataBase.ts +++ b/src/shared/context/festipodDataBase.ts @@ -169,6 +169,63 @@ export const UNSET_PROFILE = { username: '(pseudo non défini)', } as const; +// ── Display sanitizers: the UI must never render a raw identifier as a name ── +// A participant's profile may resolve but still carry an empty name, a WebID, +// an actor path, or any other identifier-shaped value instead of a human name. +// These helpers are the last line of defence: they turn such values into a +// neutral placeholder ("Participant", close to the "Participant inconnu" that +// EventDetailScreen and ParticipantsListScreen show for unresolved attendees). +// An ABSENT user (still loading / not resolved) is not masked: it renders as +// before the sanitizers existed (blank name, blank username, '?' initials), and +// screens showing the current user's own profile pass `emptyFallback: ''` so an +// empty name stays blank (cf. UNSET_PROFILE for the unset-profile wording). + +/** True when `value` looks like an identifier rather than a human-readable name. */ +function looksLikeIdentifier(value: string): boolean { + const trimmed = value.trim(); + if (trimmed.length === 0) return false; // empty is handled separately + // Any URI scheme (http:, mailto:, tel:, did:, urn:, ipfs:, …) or an absolute + // resource path (/as/actor/…, /profiles/…). Any leading `word:` is treated + // as a scheme, so a human name that starts that way ("DJ:Kool", "Lucie:)") is + // masked too — a known false positive, accepted for now. Only the START of + // the value is checked: a value embedding an identifier mid-string + // ("Bob https://…", "") is not masked. + return /^[a-z][a-z0-9+.-]*:/i.test(trimmed) || trimmed.startsWith('/'); +} + +/** + * A displayable name: the user's name unless it is empty or identifier-shaped. + * An absent user gives ''. `emptyFallback` is what an EMPTY name becomes + * ('Participant' by default; '' on the current user's own profile screens); + * an identifier-shaped name is always masked as 'Participant'. + */ +export function safeUserName(user: FpUserData | undefined, emptyFallback = 'Participant'): string { + if (!user) return ''; + const name = user.name?.trim() ?? ''; + if (name.length === 0) return emptyFallback; + return looksLikeIdentifier(name) ? 'Participant' : name; +} + +/** A displayable username: the user's username unless it is identifier-shaped. */ +export function safeUserUsername(user: FpUserData | undefined): string { + const username = user?.username?.trim() ?? ''; + if (username.length > 0 && !looksLikeIdentifier(username)) return username; + return ''; +} + +/** + * Safe initials for an avatar: explicit valid initials survive; otherwise '?'. + * An identifier-shaped name forces '?': initials derived from it ("HT" from + * "http://…") cannot be told apart from explicit ones. + */ +export function safeUserInitials(user: FpUserData | undefined): string { + if (!user) return '?'; + if (looksLikeIdentifier(user.name ?? '')) return '?'; + const initials = user.initials?.trim() ?? ''; + if (initials.length > 0 && !looksLikeIdentifier(initials)) return initials; + return '?'; +} + /** * The shared in-memory queries — the same logic for every provider, fed with * that provider's arrays. Cross-entity derivations only: nothing here touches diff --git a/src/shared/test-harness/screenSanitizers.test.tsx b/src/shared/test-harness/screenSanitizers.test.tsx new file mode 100644 index 0000000..40bfcee --- /dev/null +++ b/src/shared/test-harness/screenSanitizers.test.tsx @@ -0,0 +1,113 @@ +/** + * Screen-level wiring of the display sanitizers: renders the real screens + * (renderHelper + LocalDataProvider over the seed arrays, which are patched per + * test and restored) and asserts that no identifier-shaped value reaches the DOM. + */ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import { renderScreen, getRenderedDocument } from './renderHelper'; +import { seedUsers } from '../data/seedData'; +import type { FpUserData } from '../data/types'; + +const ID = 'http://localhost:3000/alice'; +let originals: FpUserData[] = []; + +beforeEach(() => { + originals = seedUsers.map((u) => ({ ...u })); +}); +afterEach(() => { + seedUsers.forEach((u, i) => Object.assign(u, originals[i])); +}); + +function patch(id: string, fields: Partial): void { + const u = seedUsers.find((x) => x.id === id); + if (!u) throw new Error(`seed user ${id} missing`); + Object.assign(u, fields); +} + +async function textOf(screenId: string, path: string): Promise { + await renderScreen(screenId, path); + return getRenderedDocument()?.body.textContent ?? ''; +} + +describe('lists of other people mask identifier-shaped profiles', () => { + for (const [screenId, path] of [ + ['participants', '/events/event-1/participants'], + ['event-detail', '/events/event-1'], + ] as const) { + test(`${screenId}: identifier name/username/initials never rendered`, async () => { + patch('user-2', { name: ID, username: ID, initials: 'HT' }); + const text = await textOf(screenId, path); + expect(text).not.toContain('localhost'); + expect(text).not.toContain('HT'); + expect(text).toContain('Participant'); + expect(text).toContain('?'); + }); + } +}); + +describe('someone else with an empty name', () => { + for (const [screenId, path] of [ + ['participants', '/events/event-1/participants'], + ['event-detail', '/events/event-1'], + ['user-profile', '/users/user-2'], + ] as const) { + test(`${screenId}: shown as « Participant »`, async () => { + patch('user-2', { name: '' }); + // Not the « Participants (N) » heading nor the « Participant inconnu » placeholder. + expect(await textOf(screenId, path)).toMatch(/Participant(?!s| inconnu)/); + }); + } +}); + +describe('own profile screens', () => { + test('profile: identifier name/username/initials are not rendered', async () => { + patch('user-1', { name: ID, username: ID, initials: 'HT' }); + const text = await textOf('profile', '/profile'); + expect(text).not.toContain('localhost'); + expect(text).not.toContain('HT'); + }); + + test('profile: an empty name is not titled « Participant »', async () => { + patch('user-1', { name: '' }); + expect(await textOf('profile', '/profile')).not.toContain('Participant'); + }); + + test('share-profile: no identifier in the name, initials or festipod.app/u/ link', async () => { + patch('user-1', { name: ID, username: ID, initials: 'HT' }); + const text = await textOf('share-profile', '/profile/share'); + expect(text).not.toContain('localhost'); + expect(text).not.toContain('HT'); + expect(text).not.toContain('festipod.app/u/'); + }); + + test('share-profile: a human username still builds the link; empty name is not « Participant »', async () => { + patch('user-1', { name: '' }); + const text = await textOf('share-profile', '/profile/share'); + expect(text).toContain('festipod.app/u/mariedupont'); + expect(text).not.toContain('Participant'); + }); + + test('edit-profile: identifier initials are not rendered in the avatar', async () => { + patch('user-1', { initials: ID }); + expect(await textOf('edit-profile', '/profile/edit')).not.toContain('localhost'); + }); +}); + +describe('user-profile', () => { + test('another person with an identifier-shaped profile is masked', async () => { + patch('user-2', { name: ID, username: ID, initials: 'HT' }); + const text = await textOf('user-profile', '/users/user-2'); + expect(text).not.toContain('localhost'); + expect(text).not.toContain('HT'); + expect(text).toContain('Participant'); + }); + + test('the current user viewed here with an empty name is not titled « Participant »', async () => { + patch('user-1', { name: '' }); + expect(await textOf('user-profile', '/users/user-1')).not.toContain('Participant'); + }); + + test('an unresolved user id renders no « Participant » placeholder', async () => { + expect(await textOf('user-profile', '/users/unknown-id')).not.toContain('Participant'); + }); +});