refactor(socialStore): collapse sendFriendRequest; delete federation error classes
Server now handles all parsing/routing/peering/lookup (T11-T14). Client sends the trimmed username verbatim to /api/social/requests and surfaces server errors via toast (added in T18-T20). Note: FriendsPage.tsx and UserProfileModal.tsx will fail to compile until T19 and T20 remove their now-dead try/catch blocks for the deleted error classes. TypeScript catches it; pnpm dev will not start until those tasks land.
This commit is contained in:
@@ -1,9 +1,7 @@
|
|||||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
|
||||||
const homeSendRequest = vi.fn(async () => ({ success: true, requestId: 'req-home' }));
|
const homeSendRequest = vi.fn(async () => ({ success: true, requestId: 'req-1' }));
|
||||||
const remoteSendRequest = vi.fn(async () => ({ success: true, requestId: 'req-remote' }));
|
|
||||||
const homeRequests = vi.fn(async () => []);
|
const homeRequests = vi.fn(async () => []);
|
||||||
const remoteRequests = vi.fn(async () => []);
|
|
||||||
|
|
||||||
vi.mock('../api/client', () => ({
|
vi.mock('../api/client', () => ({
|
||||||
api: {
|
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', () => ({
|
vi.mock('../utils/assetUrls', () => ({
|
||||||
normalizeUserAssets: (u: unknown) => u,
|
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';
|
import { useSocialStore } from './socialStore';
|
||||||
|
|
||||||
describe('socialStore.sendFriendRequest — case-insensitive domain routing', () => {
|
describe('socialStore.sendFriendRequest — server-side routing (post-S2S)', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
homeSendRequest.mockClear();
|
homeSendRequest.mockClear();
|
||||||
remoteSendRequest.mockClear();
|
|
||||||
homeRequests.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');
|
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(homeSendRequest).toHaveBeenCalledWith('bob');
|
||||||
expect(remoteSendRequest).not.toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('sends to the home API when the typed domain matches with mixed case', async () => {
|
it('propagates server errors and sets store.error', async () => {
|
||||||
await useSocialStore.getState().sendFriendRequest('bob@LOCAL.TEST');
|
homeSendRequest.mockRejectedValueOnce(new Error('user_not_found'));
|
||||||
expect(homeSendRequest).toHaveBeenCalledWith('bob');
|
await expect(useSocialStore.getState().sendFriendRequest('nope')).rejects.toThrow('user_not_found');
|
||||||
expect(remoteSendRequest).not.toHaveBeenCalled();
|
expect(useSocialStore.getState().error).toBe('user_not_found');
|
||||||
});
|
|
||||||
|
|
||||||
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();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,24 +4,6 @@ import { api } from '../api/client';
|
|||||||
import { useInstanceStore } from './instanceStore';
|
import { useInstanceStore } from './instanceStore';
|
||||||
import { normalizeUserAssets } from '../utils/assetUrls';
|
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) ───────────────────────────
|
// ─── Tagged types (origin tracking for federation) ───────────────────────────
|
||||||
|
|
||||||
export type TaggedFriend = Friend & { _instanceOrigin: string };
|
export type TaggedFriend = Friend & { _instanceOrigin: string };
|
||||||
@@ -210,44 +192,11 @@ export const useSocialStore = create<SocialState>((set, get) => ({
|
|||||||
sendFriendRequest: async (username: string) => {
|
sendFriendRequest: async (username: string) => {
|
||||||
set({ isLoading: true, error: null });
|
set({ isLoading: true, error: null });
|
||||||
try {
|
try {
|
||||||
const atIndex = username.lastIndexOf('@');
|
const res = await api.social.sendRequest(username.trim());
|
||||||
let res: { success: boolean; requestId?: string };
|
set({ isLoading: false });
|
||||||
|
// Server emits friend_request_sent over WS; useWebSocket appends the row
|
||||||
if (atIndex === -1) {
|
// optimistically. As a safety net for tabs that race the WS event, refresh
|
||||||
// No @ → local user on home instance
|
// from server too.
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await get().loadRequests();
|
await get().loadRequests();
|
||||||
return res.requestId;
|
return res.requestId;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
Reference in New Issue
Block a user