diff --git a/packages/web/src/stores/socialStore.sendRequest.test.ts b/packages/web/src/stores/socialStore.sendRequest.test.ts index 367dddd1..a975c44b 100644 --- a/packages/web/src/stores/socialStore.sendRequest.test.ts +++ b/packages/web/src/stores/socialStore.sendRequest.test.ts @@ -1,9 +1,7 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; -const homeSendRequest = vi.fn(async () => ({ success: true, requestId: 'req-home' })); -const remoteSendRequest = vi.fn(async () => ({ success: true, requestId: 'req-remote' })); +const homeSendRequest = vi.fn(async () => ({ success: true, requestId: 'req-1' })); const homeRequests = vi.fn(async () => []); -const remoteRequests = vi.fn(async () => []); vi.mock('../api/client', () => ({ api: { @@ -14,75 +12,52 @@ vi.mock('../api/client', () => ({ }, })); -const remoteApi = { - social: { - sendRequest: (...args: unknown[]) => remoteSendRequest(...args), - requests: () => remoteRequests(), - }, -}; - -vi.mock('./instanceStore', () => ({ - useInstanceStore: { - getState: () => ({ - instances: [ - { - origin: 'https://orbit.ddns.net', - status: 'connected', - api: remoteApi, - }, - ], - }), - }, -})); - vi.mock('../utils/assetUrls', () => ({ normalizeUserAssets: (u: unknown) => u, })); +// instanceStore is still imported by other socialStore methods (loadFriends, loadRequests) +// — provide an empty-instances stub so those calls don't crash. +vi.mock('./instanceStore', () => ({ + useInstanceStore: { + getState: () => ({ instances: [], _autoConnectDone: true }), + subscribe: () => () => {}, + }, +})); + import { useSocialStore } from './socialStore'; -describe('socialStore.sendFriendRequest — case-insensitive domain routing', () => { +describe('socialStore.sendFriendRequest — server-side routing (post-S2S)', () => { beforeEach(() => { homeSendRequest.mockClear(); - remoteSendRequest.mockClear(); homeRequests.mockClear(); - remoteRequests.mockClear(); - // window.location.host in jsdom defaults to 'localhost:3000' or similar. - // Override it for routing tests. - Object.defineProperty(window, 'location', { - configurable: true, - writable: true, - value: { ...window.location, host: 'local.test', hostname: 'local.test' }, - }); }); - it('sends to the home API when the typed domain matches window.location.host exactly', async () => { + it('sends bare handle to home API as-is', async () => { + const id = await useSocialStore.getState().sendFriendRequest('bob'); + expect(homeSendRequest).toHaveBeenCalledOnce(); + expect(homeSendRequest).toHaveBeenCalledWith('bob'); + expect(id).toBe('req-1'); + }); + + it('sends @-handle to home API verbatim (server handles routing)', async () => { + await useSocialStore.getState().sendFriendRequest('bob@orbit.tld'); + expect(homeSendRequest).toHaveBeenCalledWith('bob@orbit.tld'); + }); + + it('sends @-handle for own host to home API verbatim', async () => { await useSocialStore.getState().sendFriendRequest('bob@local.test'); + expect(homeSendRequest).toHaveBeenCalledWith('bob@local.test'); + }); + + it('trims whitespace before sending', async () => { + await useSocialStore.getState().sendFriendRequest(' bob '); expect(homeSendRequest).toHaveBeenCalledWith('bob'); - expect(remoteSendRequest).not.toHaveBeenCalled(); }); - it('sends to the home API when the typed domain matches with mixed case', async () => { - await useSocialStore.getState().sendFriendRequest('bob@LOCAL.TEST'); - expect(homeSendRequest).toHaveBeenCalledWith('bob'); - expect(remoteSendRequest).not.toHaveBeenCalled(); - }); - - it('routes to a connected remote instance when the typed domain matches its origin host', async () => { - await useSocialStore.getState().sendFriendRequest('bob@orbit.ddns.net'); - expect(remoteSendRequest).toHaveBeenCalledWith('bob'); - expect(homeSendRequest).not.toHaveBeenCalled(); - }); - - it('routes to a connected remote instance when the typed domain has mixed case', async () => { - await useSocialStore.getState().sendFriendRequest('bob@ORBIT.ddns.net'); - expect(remoteSendRequest).toHaveBeenCalledWith('bob'); - expect(homeSendRequest).not.toHaveBeenCalled(); - }); - - it('sends bare handle (no @) directly to the home API', async () => { - await useSocialStore.getState().sendFriendRequest('bob'); - expect(homeSendRequest).toHaveBeenCalledWith('bob'); - expect(remoteSendRequest).not.toHaveBeenCalled(); + it('propagates server errors and sets store.error', async () => { + homeSendRequest.mockRejectedValueOnce(new Error('user_not_found')); + await expect(useSocialStore.getState().sendFriendRequest('nope')).rejects.toThrow('user_not_found'); + expect(useSocialStore.getState().error).toBe('user_not_found'); }); }); diff --git a/packages/web/src/stores/socialStore.ts b/packages/web/src/stores/socialStore.ts index 9a0878e6..22eda4f6 100644 --- a/packages/web/src/stores/socialStore.ts +++ b/packages/web/src/stores/socialStore.ts @@ -4,24 +4,6 @@ import { api } from '../api/client'; import { useInstanceStore } from './instanceStore'; import { normalizeUserAssets } from '../utils/assetUrls'; -// ─── Federation errors ──────────────────────────────────────────────────── - -/** Thrown when the target domain has never been connected. */ -export class InstanceNotConnectedError extends Error { - constructor(public domain: string) { - super(`Not connected to ${domain}`); - this.name = 'InstanceNotConnectedError'; - } -} - -/** Thrown when the instance entry exists but the session is disconnected/errored. */ -export class InstanceDisconnectedError extends Error { - constructor(public domain: string) { - super(`Instance ${domain} is not currently connected`); - this.name = 'InstanceDisconnectedError'; - } -} - // ─── Tagged types (origin tracking for federation) ─────────────────────────── export type TaggedFriend = Friend & { _instanceOrigin: string }; @@ -210,44 +192,11 @@ export const useSocialStore = create((set, get) => ({ sendFriendRequest: async (username: string) => { set({ isLoading: true, error: null }); try { - const atIndex = username.lastIndexOf('@'); - let res: { success: boolean; requestId?: string }; - - if (atIndex === -1) { - // No @ → local user on home instance - res = await api.social.sendRequest(username); - } else { - const baseName = username.slice(0, atIndex); - const domain = username.slice(atIndex + 1).toLowerCase(); - - // Check if domain matches home instance - if (domain === window.location.host) { - // Strip domain, send to home API - res = await api.social.sendRequest(baseName); - } else { - // Find a connected instance matching this domain - const instances = useInstanceStore.getState().instances; - const match = instances.find(inst => { - try { - return new URL(inst.origin).host === domain; - } catch { - return false; - } - }); - - if (!match) { - throw new InstanceNotConnectedError(domain); - } - - if (match.status !== 'connected') { - throw new InstanceDisconnectedError(domain); - } - - // On the remote instance, the user is just "alice", not "alice@orbit" - res = await match.api.social.sendRequest(baseName); - } - } - + const res = await api.social.sendRequest(username.trim()); + set({ isLoading: false }); + // Server emits friend_request_sent over WS; useWebSocket appends the row + // optimistically. As a safety net for tabs that race the WS event, refresh + // from server too. await get().loadRequests(); return res.requestId; } catch (err) {