diff --git a/src/lib/api/coves/client.ts b/src/lib/api/coves/client.ts index 6ea52c77..397dbd6b 100644 --- a/src/lib/api/coves/client.ts +++ b/src/lib/api/coves/client.ts @@ -36,13 +36,13 @@ import type { export const NSID = { getDiscover: 'social.coves.feed.getDiscover', getTimeline: 'social.coves.feed.getTimeline', - getCommunityFeed: 'social.coves.feed.getCommunity', + getCommunityFeed: 'social.coves.communityFeed.getCommunity', getComments: 'social.coves.community.comment.getComments', createComment: 'social.coves.community.comment.create', deleteComment: 'social.coves.community.comment.delete', createVote: 'social.coves.feed.vote.create', deleteVote: 'social.coves.feed.vote.delete', - getProfile: 'social.coves.actor.getProfile', + getProfile: 'social.coves.actor.getprofile', getActorPosts: 'social.coves.actor.getPosts', getActorComments: 'social.coves.actor.getComments', blockUser: 'social.coves.actor.blockUser', diff --git a/src/lib/app/markdown/renderers/plugins.js b/src/lib/app/markdown/renderers/plugins.js index 932d96c0..a83bacf7 100644 --- a/src/lib/app/markdown/renderers/plugins.js +++ b/src/lib/app/markdown/renderers/plugins.js @@ -82,7 +82,7 @@ export const linkify = markedLinkifyIt( let prefix = match.url prefix = prefix.startsWith('u/') ? prefix.slice(2) : prefix.slice(1) - match.url = `/u/${prefix}` + match.url = `/profile/${prefix}` }, }, }, @@ -102,7 +102,7 @@ const regexes = { export { regexes as CONTENT_REGEXES } /** - * Convert links to photon links + * Convert links to local app links */ export const photonify = (link) => { if (regexes.community.test(link)) { @@ -128,15 +128,15 @@ export const photonify = (link) => { if (!match) return // Same as above for the community. - if (match?.[3].includes('@')) return `/u/${match?.[3]}` - else return `/u/${match?.[3]}@${match?.[1]}` + if (match?.[3].includes('@')) return `/profile/${match?.[3]}` + else return `/profile/${match?.[3]}@${match?.[1]}` } // Support implicit user syntax (no preceding @), by messing with mailto links. if (regexes.implicitUser.test(link)) { const exec = regexes.implicitUser.exec(link) if (!exec?.[1] || !exec?.[2]) return - return `/u/${exec[1]}@${exec[2]}` + return `/profile/${exec[1]}@${exec[2]}` } } diff --git a/src/lib/app/markdown/renderers/plugins.test.ts b/src/lib/app/markdown/renderers/plugins.test.ts new file mode 100644 index 00000000..67654f94 --- /dev/null +++ b/src/lib/app/markdown/renderers/plugins.test.ts @@ -0,0 +1,150 @@ +import { describe, it, expect } from 'vitest' +import { photonify, CONTENT_REGEXES } from './plugins' + +// --------------------------------------------------------------------------- +// photonify() - user links +// --------------------------------------------------------------------------- + +describe('photonify - user links', () => { + it('rewrites user link with @ to /profile/ path (no instance appended)', () => { + const result = photonify('https://lemmy.world/u/alice@instance.com') + expect(result).toBe('/profile/alice@instance.com') + }) + + it('rewrites user link without @ to /profile/ path with instance appended', () => { + const result = photonify('https://lemmy.world/u/alice') + expect(result).toBe('/profile/alice@lemmy.world') + }) + + it('handles user link with dots in username', () => { + const result = photonify('https://example.com/u/user.name') + expect(result).toBe('/profile/user.name@example.com') + }) + + it('handles user link with underscores in username', () => { + const result = photonify('https://example.com/u/my_user') + expect(result).toBe('/profile/my_user@example.com') + }) +}) + +// --------------------------------------------------------------------------- +// photonify() - implicit user links (mailto) +// --------------------------------------------------------------------------- + +describe('photonify - implicit user links (mailto)', () => { + it('rewrites mailto link to /profile/ path', () => { + const result = photonify('mailto:alice@coves.social') + expect(result).toBe('/profile/alice@coves.social') + }) + + it('rewrites mailto link with subdomain instance', () => { + const result = photonify('mailto:bob@lemmy.world') + expect(result).toBe('/profile/bob@lemmy.world') + }) + + it('handles username with dots and hyphens', () => { + const result = photonify('mailto:first.last@example.org') + expect(result).toBe('/profile/first.last@example.org') + }) +}) + +// --------------------------------------------------------------------------- +// photonify() - community links +// --------------------------------------------------------------------------- + +describe('photonify - community links', () => { + it('rewrites community link without @ to /c/ path with instance appended', () => { + const result = photonify('https://lemmy.world/c/technology') + expect(result).toBe('/c/technology@lemmy.world') + }) + + it('rewrites community link with @ to /c/ path (no instance appended)', () => { + const result = photonify('https://lemmy.world/c/tech@other.instance') + expect(result).toBe('/c/tech@other.instance') + }) +}) + +// --------------------------------------------------------------------------- +// photonify() - post links +// --------------------------------------------------------------------------- + +describe('photonify - post links', () => { + it('rewrites post link to /post/{instance}/{id} path', () => { + const result = photonify('https://lemmy.world/post/12345') + expect(result).toBe('/post/lemmy.world/12345') + }) +}) + +// --------------------------------------------------------------------------- +// photonify() - comment links +// --------------------------------------------------------------------------- + +describe('photonify - comment links', () => { + it('rewrites comment link to /comment/{instance}/{id} path', () => { + const result = photonify('https://lemmy.world/comment/6789') + expect(result).toBe('/comment/lemmy.world/6789') + }) +}) + +// --------------------------------------------------------------------------- +// photonify() - non-matching links +// --------------------------------------------------------------------------- + +describe('photonify - non-matching links', () => { + it('returns undefined for a generic URL', () => { + const result = photonify('https://example.com/some/page') + expect(result).toBeUndefined() + }) + + it('returns undefined for an empty string', () => { + const result = photonify('') + expect(result).toBeUndefined() + }) + + it('returns undefined for a plain text string', () => { + const result = photonify('not a url') + expect(result).toBeUndefined() + }) + + it('returns undefined for a URL with unsupported path', () => { + const result = photonify('https://lemmy.world/settings') + expect(result).toBeUndefined() + }) +}) + +// --------------------------------------------------------------------------- +// CONTENT_REGEXES - verify exported patterns +// --------------------------------------------------------------------------- + +describe('CONTENT_REGEXES', () => { + it('exports post regex', () => { + expect(CONTENT_REGEXES.post).toBeInstanceOf(RegExp) + expect(CONTENT_REGEXES.post.test('https://lemmy.world/post/123')).toBe(true) + }) + + it('exports comment regex', () => { + expect(CONTENT_REGEXES.comment).toBeInstanceOf(RegExp) + expect( + CONTENT_REGEXES.comment.test('https://lemmy.world/comment/456'), + ).toBe(true) + }) + + it('exports user regex', () => { + expect(CONTENT_REGEXES.user).toBeInstanceOf(RegExp) + expect(CONTENT_REGEXES.user.test('https://lemmy.world/u/alice')).toBe(true) + }) + + it('exports community regex', () => { + expect(CONTENT_REGEXES.community).toBeInstanceOf(RegExp) + expect(CONTENT_REGEXES.community.test('https://lemmy.world/c/tech')).toBe( + true, + ) + }) + + it('exports implicitUser regex', () => { + expect(CONTENT_REGEXES.implicitUser).toBeInstanceOf(RegExp) + expect(CONTENT_REGEXES.implicitUser.test('mailto:alice@coves.social')).toBe( + true, + ) + }) +}) diff --git a/src/lib/app/util.svelte.test.ts b/src/lib/app/util.svelte.test.ts index b0585c3d..88dd9bc3 100644 --- a/src/lib/app/util.svelte.test.ts +++ b/src/lib/app/util.svelte.test.ts @@ -153,20 +153,20 @@ describe('userLink', () => { handle: 'alice.coves.social' as Handle, } - it('returns /u/{handle} for AuthorView with handle', () => { - expect(userLink(author)).toBe('/u/alice.coves.social') + it('returns /profile/{handle} for AuthorView with handle', () => { + expect(userLink(author)).toBe('/profile/alice.coves.social') }) - it('returns /u/{did} for AuthorView without handle', () => { + it('returns /profile/{did} for AuthorView without handle', () => { const noHandle: AuthorView = { did: 'did:plc:user1' as DID, handle: '' as Handle, } - expect(userLink(noHandle)).toBe('/u/did%3Aplc%3Auser1') + expect(userLink(noHandle)).toBe('/profile/did%3Aplc%3Auser1') }) it('prepends prefix when provided', () => { - expect(userLink(author, '/app')).toBe('/app/u/alice.coves.social') + expect(userLink(author, '/app')).toBe('/app/profile/alice.coves.social') }) }) diff --git a/src/lib/app/util.svelte.ts b/src/lib/app/util.svelte.ts index 82e1c857..d2b4800e 100644 --- a/src/lib/app/util.svelte.ts +++ b/src/lib/app/util.svelte.ts @@ -227,9 +227,9 @@ export function communityLink( */ export function userLink(user: AuthorView, prefix: string = ''): string { if (user.handle) { - return `${prefix}/u/${encodeURIComponent(user.handle)}` + return `${prefix}/profile/${encodeURIComponent(user.handle)}` } - return `${prefix}/u/${encodeURIComponent(user.did)}` + return `${prefix}/profile/${encodeURIComponent(user.did)}` } /** diff --git a/src/lib/feature/feeds/feed.svelte.ts b/src/lib/feature/feeds/feed.svelte.ts index c03cf652..0e718422 100644 --- a/src/lib/feature/feeds/feed.svelte.ts +++ b/src/lib/feature/feeds/feed.svelte.ts @@ -15,7 +15,7 @@ import { profile } from '$lib/app/auth.svelte' import { recursiveEqual } from '$lib/app/util.svelte' import { SvelteMap } from 'svelte/reactivity' -// TODO(coves-migration): Remove this stub once legacy routes (/f/[id], /topic/[id], /profile/user) +// TODO(coves-migration): Remove this stub once legacy routes (/f/[id], /topic/[id]) // are migrated to Coves API. It exists only to keep FeedTypes typings for unmigrated routes. /** Placeholder for legacy Lemmy types in unmigrated routes. */ type LegacyRecord = Record @@ -82,7 +82,7 @@ export interface FeedTypes { params: FeedPaginationParams & { community: string; cursor?: string } }, ] - '/u/[handle]': [ + '/profile/[handle=handle]': [ { actor: string; limit?: number; cursor?: string; sort?: string }, { profile: ProfileViewDetailed @@ -154,8 +154,6 @@ export interface FeedTypes { } }, ] - // TODO(coves-migration): convert to Coves types — legacy Lemmy profile route - '/profile/user': [LegacyRecord, LegacyRecord] } export const feeds = new SvelteMap>() diff --git a/src/lib/feature/user/UserItem.svelte b/src/lib/feature/user/UserItem.svelte index cfa937a5..62e90011 100644 --- a/src/lib/feature/user/UserItem.svelte +++ b/src/lib/feature/user/UserItem.svelte @@ -1,5 +1,6 @@ + + {$t('profile.profile')} + + +
+ + + {#snippet target(attachment)} + + {/snippet} + + {$t('routes.profile.media.title')} + + + {$t('routes.profile.upvoted')} + + + {$t('routes.profile.downvoted')} + + + +
{@render children?.()} diff --git a/src/routes/profile/(local_user)/+layout.ts b/src/routes/profile/(local_user)/+layout.ts new file mode 100644 index 00000000..a5484b44 --- /dev/null +++ b/src/routes/profile/(local_user)/+layout.ts @@ -0,0 +1,11 @@ +import { profile } from '$lib/app/auth.svelte' +import { error } from '@sveltejs/kit' + +export function load() { + if (profile.current.type !== 'authenticated') error(401) + + return { + // TODO(coves-migration): Fetch from Coves API when available + my_user: undefined, + } +} diff --git a/src/routes/profile/(local_user)/blocks/instances/+page.svelte b/src/routes/profile/(local_user)/blocks/instances/+page.svelte index 3bb13841..2bb52d0c 100644 --- a/src/routes/profile/(local_user)/blocks/instances/+page.svelte +++ b/src/routes/profile/(local_user)/blocks/instances/+page.svelte @@ -8,10 +8,20 @@ let { data } = $props() + // TODO(coves-migration): Needs Coves instance block API — my_user is undefined during migration + // Cast required because my_user is typed as undefined until Coves API provides instance blocks + type InstanceBlock = { + instance: { id: number; domain: string } + site?: { name?: string; icon?: string } + } + type MyUser = { instance_blocks?: InstanceBlock[] } + const myUser = $derived(data.my_user as unknown as MyUser | undefined) + const instanceBlocks = $derived(myUser?.instance_blocks) + async function unblock(id: number) { - if (!data.my_user?.instance_blocks) return - data.my_user?.instance_blocks.splice( - data.my_user?.instance_blocks.findIndex((i) => i.instance.id == id), + if (!instanceBlocks) return + instanceBlocks.splice( + instanceBlocks.findIndex((i) => i.instance.id == id), 1, ) @@ -22,9 +32,9 @@ } -{#if data.my_user?.instance_blocks && data.my_user?.instance_blocks?.length > 0} +{#if instanceBlocks && instanceBlocks.length > 0} ({ + items={instanceBlocks.map((i) => ({ id: i.instance.id, name: i.site?.name ?? i.instance.domain, avatar: i.site?.icon, diff --git a/src/routes/profile/+layout.svelte b/src/routes/profile/+layout.svelte index 9e39823a..9319e00b 100644 --- a/src/routes/profile/+layout.svelte +++ b/src/routes/profile/+layout.svelte @@ -1,63 +1,5 @@ - - {$t('profile.profile')} - - -
- - - {#snippet target(attachment)} - - {/snippet} - - {$t('routes.profile.media.title')} - - - {$t('routes.profile.upvoted')} - - - {$t('routes.profile.downvoted')} - - - -
{@render children?.()} diff --git a/src/routes/profile/+layout.ts b/src/routes/profile/+layout.ts index e831393b..62ad4e4f 100644 --- a/src/routes/profile/+layout.ts +++ b/src/routes/profile/+layout.ts @@ -1,22 +1 @@ -import { client } from '$lib/api/client.svelte' -import { profile } from '$lib/app/auth.svelte' -import { error } from '@sveltejs/kit' - -// disable ssr, as the server cannot be authenticated export const ssr = false - -export async function load({ fetch }) { - if (!profile.current.jwt) error(401) - - // TODO: Fetch user data from Coves API using DID - const siteData = await client({ auth: profile.current?.jwt, func: fetch }).getSite() - const my_user = siteData.my_user - - return { - my_user: my_user, - community_blocks: my_user?.community_blocks, - person_blocks: my_user?.person_blocks, - follows: my_user?.follows, - moderates: my_user?.moderates, - } -} diff --git a/src/routes/profile/+page.ts b/src/routes/profile/+page.ts index 3ea2d2e6..4a4a9c25 100644 --- a/src/routes/profile/+page.ts +++ b/src/routes/profile/+page.ts @@ -1,5 +1,10 @@ import { redirect } from '@sveltejs/kit' +import { profile } from '$lib/app/auth.svelte' export function load() { - redirect(302, '/profile/user') + if (profile.current.type === 'authenticated') { + const identifier = profile.current.handle ?? profile.current.did + redirect(302, `/profile/${encodeURIComponent(identifier)}`) + } + redirect(302, '/login') } diff --git a/src/routes/u/[handle]/+page.svelte b/src/routes/profile/[handle=handle]/+page.svelte similarity index 96% rename from src/routes/u/[handle]/+page.svelte rename to src/routes/profile/[handle=handle]/+page.svelte index 825b5b44..8f091828 100644 --- a/src/routes/u/[handle]/+page.svelte +++ b/src/routes/profile/[handle=handle]/+page.svelte @@ -168,5 +168,11 @@ description="This user has no submissions." /> {/if} + {:else} + {/if} diff --git a/src/routes/profile/[handle=handle]/+page.ts b/src/routes/profile/[handle=handle]/+page.ts new file mode 100644 index 00000000..864a7519 --- /dev/null +++ b/src/routes/profile/[handle=handle]/+page.ts @@ -0,0 +1,53 @@ +import { error } from '@sveltejs/kit' +import { coves } from '$lib/api/client.svelte' +import { isValidDID, isValidHandle } from '$lib/types/atproto' +import { ReactiveState } from '$lib/app/util.svelte' +import { feed } from '$lib/feature/feeds/feed.svelte' + +export async function load({ params, url, fetch, route }) { + const cursor = url.searchParams.get('cursor') ?? undefined + const sort = url.searchParams.get('sort') ?? 'new' + + const feedData = await feed(route.id, async (p) => { + if (!isValidHandle(p.actor) && !isValidDID(p.actor)) { + error(400, 'Invalid user identifier') + } + const actor = p.actor + + try { + const [profileData, postsData, commentsData] = await Promise.all([ + coves({ func: fetch }).getProfile({ actor }), + coves({ func: fetch }).getActorPosts({ + actor, + limit: p.limit, + cursor: p.cursor, + }), + coves({ func: fetch }).getActorComments({ + actor, + limit: p.limit, + cursor: p.cursor, + }), + ]) + + return { + profile: profileData, + posts: postsData, + comments: commentsData, + } + } catch (err) { + if (err instanceof Error && err.message.includes('not found')) { + error(404, 'User not found') + } + error(500, 'Failed to load profile') + } + }).load({ + actor: params.handle, + limit: 20, + cursor, + sort, + }) + + return { + data: new ReactiveState(feedData), + } +} diff --git a/src/routes/u/[handle]/UserActions.svelte b/src/routes/profile/[handle=handle]/UserActions.svelte similarity index 100% rename from src/routes/u/[handle]/UserActions.svelte rename to src/routes/profile/[handle=handle]/UserActions.svelte diff --git a/src/routes/profile/page.test.ts b/src/routes/profile/page.test.ts new file mode 100644 index 00000000..2d1f997a --- /dev/null +++ b/src/routes/profile/page.test.ts @@ -0,0 +1,108 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +interface RedirectError { + status: number + location: string +} + +// Use vi.hoisted to define mutable state accessible in the mock factory +const mockProfile = vi.hoisted(() => ({ + current: { + type: 'guest' as 'guest' | 'authenticated', + handle: undefined as string | undefined, + did: undefined as string | undefined, + }, +})) + +vi.mock('$lib/app/auth.svelte', () => ({ + profile: mockProfile, +})) + +import { load } from './+page' + +describe('/profile redirect', () => { + beforeEach(() => { + mockProfile.current = { type: 'guest', handle: undefined, did: undefined } + }) + + it('redirects to /profile/{handle} when user has a handle', () => { + mockProfile.current = { + type: 'authenticated', + handle: 'alice.coves.social', + did: 'did:plc:abc123', + } + + try { + load() + expect.fail('Expected redirect to be thrown') + } catch (e: unknown) { + const redirect = e as RedirectError + expect(redirect.status).toBe(302) + expect(redirect.location).toBe('/profile/alice.coves.social') + } + }) + + it('redirects to /profile/{did} when user has no handle but has a DID', () => { + mockProfile.current = { + type: 'authenticated', + handle: undefined, + did: 'did:plc:abc123', + } + + try { + load() + expect.fail('Expected redirect to be thrown') + } catch (e: unknown) { + const redirect = e as RedirectError + expect(redirect.status).toBe(302) + expect(redirect.location).toBe('/profile/did%3Aplc%3Aabc123') + } + }) + + it('redirects to /login when user is a guest', () => { + mockProfile.current = { type: 'guest', handle: undefined, did: undefined } + + try { + load() + expect.fail('Expected redirect to be thrown') + } catch (e: unknown) { + const redirect = e as RedirectError + expect(redirect.status).toBe(302) + expect(redirect.location).toBe('/login') + } + }) + + it('encodes special characters in handle', () => { + mockProfile.current = { + type: 'authenticated', + handle: 'user@example.com', + did: 'did:plc:xyz789', + } + + try { + load() + expect.fail('Expected redirect to be thrown') + } catch (e: unknown) { + const redirect = e as RedirectError + expect(redirect.status).toBe(302) + expect(redirect.location).toBe('/profile/user%40example.com') + } + }) + + it('redirects to /login when user type is guest even with handle', () => { + mockProfile.current = { + type: 'guest', + handle: 'stale-handle', + did: undefined, + } + + try { + load() + expect.fail('Expected redirect to be thrown') + } catch (e: unknown) { + const redirect = e as RedirectError + expect(redirect.status).toBe(302) + expect(redirect.location).toBe('/login') + } + }) +}) diff --git a/src/routes/profile/user/+page.svelte b/src/routes/profile/user/+page.svelte deleted file mode 100644 index ec893e8b..00000000 --- a/src/routes/profile/user/+page.svelte +++ /dev/null @@ -1,28 +0,0 @@ - - -{#if data.user && data.sort && data.type && data.page} - -{:else} - User data is missing. -
-    {JSON.stringify(data)}
-  
-{/if} diff --git a/src/routes/profile/user/+page.ts b/src/routes/profile/user/+page.ts deleted file mode 100644 index b64816f1..00000000 --- a/src/routes/profile/user/+page.ts +++ /dev/null @@ -1,59 +0,0 @@ -// eslint-disable-next-line @typescript-eslint/ban-ts-comment -// @ts-nocheck TODO(coves-migration): Needs Coves user profile API -import { client } from '$lib/api/client.svelte' -import type { SortType } from '$lib/api/types' -import { feed } from '$lib/feature/feeds/feed.svelte.js' -import { getItemPublished } from '$lib/feature/legacy/item.svelte' - -export async function load({ url, fetch, parent, route }) { - const page = Number(url.searchParams.get('page')) || 1 - const type: 'comments' | 'posts' | 'all' = - (url.searchParams.get('type') as 'comments' | 'posts' | 'all') || 'all' - const sort: SortType = (url.searchParams.get('sort') as SortType) || 'New' - - const myUser = await parent() - const feedData = await feed(route.id, (params) => - client({ func: fetch }).getPersonDetails({ - limit: params.limit, - page: params.page, - person_id: myUser?.my_user?.local_user_view.person.id, - sort: params.sort, - }), - ).load({ - limit: 20, - page: page, - person_id: myUser.my_user?.local_user_view.person.id, - sort: sort, - }) - - const items = [ - ...(type == 'all' || type == 'posts' ? feedData.posts : []), - ...(type == 'all' || type == 'comments' ? feedData.comments : []), - ] - - if (sort == 'TopAll') { - items.sort( - (a, b) => - b.counts.upvotes - - b.counts.downvotes - - (a.counts.upvotes - a.counts.downvotes), - ) - } else if (sort == 'New') { - items.sort( - (a, b) => - Date.parse(getItemPublished(b)) - Date.parse(getItemPublished(a)), - ) - } - - return { - type: type, - page: page, - sort: sort, - limit: 20, - user: { - submissions: items, - moderates: feedData.moderates, - person_view: feedData.person_view, - }, - } -} diff --git a/src/routes/u/[handle]/+page.ts b/src/routes/u/[handle]/+page.ts index 3157aaa4..9b87cddd 100644 --- a/src/routes/u/[handle]/+page.ts +++ b/src/routes/u/[handle]/+page.ts @@ -1,41 +1,5 @@ -import { coves } from '$lib/api/client.svelte' -import type { Handle } from '$lib/types/atproto' -import { ReactiveState } from '$lib/app/util.svelte' -import { feed } from '$lib/feature/feeds/feed.svelte' +import { redirect } from '@sveltejs/kit' -export async function load({ params, url, fetch, route }) { - const cursor = url.searchParams.get('cursor') ?? undefined - const sort = url.searchParams.get('sort') ?? 'new' - - const feedData = await feed(route.id, async (p) => { - const actor = p.actor as Handle - const [profileData, postsData, commentsData] = await Promise.all([ - coves({ func: fetch }).getProfile({ actor }), - coves({ func: fetch }).getActorPosts({ - actor, - limit: p.limit, - cursor: p.cursor, - }), - coves({ func: fetch }).getActorComments({ - actor, - limit: p.limit, - cursor: p.cursor, - }), - ]) - - return { - profile: profileData, - posts: postsData, - comments: commentsData, - } - }).load({ - actor: params.handle, - limit: 20, - cursor, - sort, - }) - - return { - data: new ReactiveState(feedData), - } +export function load({ params, url }) { + redirect(301, `/profile/${encodeURIComponent(params.handle)}${url.search}`) } diff --git a/src/routes/u/[handle]/page.test.ts b/src/routes/u/[handle]/page.test.ts new file mode 100644 index 00000000..0c3e5a19 --- /dev/null +++ b/src/routes/u/[handle]/page.test.ts @@ -0,0 +1,65 @@ +import { describe, it, expect } from 'vitest' +import { load } from './+page' + +interface RedirectError { + status: number + location: string +} + +describe('/u/[handle] redirect', () => { + it('redirects to /profile/{handle} with 301 status', () => { + const params = { handle: 'alice' } + const url = new URL('http://localhost/u/alice') + + try { + load({ params, url } as Parameters[0]) + expect.fail('Expected redirect to be thrown') + } catch (e: unknown) { + const redirect = e as RedirectError + expect(redirect.status).toBe(301) + expect(redirect.location).toBe('/profile/alice') + } + }) + + it('preserves query parameters in redirect', () => { + const params = { handle: 'alice' } + const url = new URL('http://localhost/u/alice?sort=top&page=2') + + try { + load({ params, url } as Parameters[0]) + expect.fail('Expected redirect to be thrown') + } catch (e: unknown) { + const redirect = e as RedirectError + expect(redirect.status).toBe(301) + expect(redirect.location).toBe('/profile/alice?sort=top&page=2') + } + }) + + it('encodes special characters in handle', () => { + const params = { handle: 'user@example.com' } + const url = new URL('http://localhost/u/user@example.com') + + try { + load({ params, url } as Parameters[0]) + expect.fail('Expected redirect to be thrown') + } catch (e: unknown) { + const redirect = e as RedirectError + expect(redirect.status).toBe(301) + expect(redirect.location).toBe('/profile/user%40example.com') + } + }) + + it('redirects with empty query string when no params', () => { + const params = { handle: 'bob' } + const url = new URL('http://localhost/u/bob') + + try { + load({ params, url } as Parameters[0]) + expect.fail('Expected redirect to be thrown') + } catch (e: unknown) { + const redirect = e as RedirectError + expect(redirect.status).toBe(301) + expect(redirect.location).toBe('/profile/bob') + } + }) +}) diff --git a/static/manifest.json b/static/manifest.json index a23de728..4bfcc8bd 100644 --- a/static/manifest.json +++ b/static/manifest.json @@ -47,7 +47,7 @@ }, { "name": "Profile", - "url": "/profile/user" + "url": "/profile" } ], "share_target": {