feat(client): api.dm.updateMetadata + kickMember + transferOwnership
This commit is contained in:
@@ -65,6 +65,7 @@ import type {
|
||||
SpaceInviteRequest,
|
||||
SpaceInviteResponse,
|
||||
} from '@backspace/shared';
|
||||
import { getApiForOrigin, getOwnerInstanceForDm } from '../utils/crossStoreResolvers';
|
||||
|
||||
export type { FederationPeer, ApprovalRequest, PeeringSubscription, PeeringNotification };
|
||||
|
||||
@@ -174,6 +175,23 @@ export class BackspaceApiClient {
|
||||
deleteMessage: (id: string) => Promise<{ success: boolean }>;
|
||||
addMember: (dmChannelId: string, data: AddDmMemberRequest) => Promise<DmChannel>;
|
||||
leave: (dmChannelId: string) => Promise<{ success: boolean }>;
|
||||
/**
|
||||
* Owner-only: rename a group DM and/or update its icon.
|
||||
* Routes via getApiForOrigin(getOwnerInstanceForDm(channelId)) so the
|
||||
* federation event's sourceInstance equals the channel's ownerHomeInstance
|
||||
* (required by the receiver's authority check).
|
||||
*/
|
||||
updateMetadata: (channelId: string, body: { name?: string | null; icon?: string | null }) => Promise<DmChannel>;
|
||||
/**
|
||||
* Owner-only: kick a member from a group DM.
|
||||
* Routes via getApiForOrigin(getOwnerInstanceForDm(channelId)) — see updateMetadata.
|
||||
*/
|
||||
kickMember: (channelId: string, targetUserId: string) => Promise<{ success: boolean }>;
|
||||
/**
|
||||
* Owner-only: transfer group DM ownership to another member without leaving.
|
||||
* Routes via getApiForOrigin(getOwnerInstanceForDm(channelId)) — see updateMetadata.
|
||||
*/
|
||||
transferOwnership: (channelId: string, newOwnerId: string) => Promise<DmChannel>;
|
||||
spaceInvite: (body: SpaceInviteRequest) => Promise<SpaceInviteResponse>;
|
||||
};
|
||||
|
||||
@@ -486,6 +504,30 @@ export class BackspaceApiClient {
|
||||
request<DmChannel>('POST', `/dm/${dmChannelId}/members`, data),
|
||||
leave: (dmChannelId: string) =>
|
||||
request<{ success: boolean }>('DELETE', `/dm/${dmChannelId}/members`),
|
||||
// Owner-only methods. Each first re-routes through the owner's home
|
||||
// instance via getApiForOrigin(getOwnerInstanceForDm(channelId)). When
|
||||
// the resolved client is `this`, we fall through to the local request
|
||||
// (terminating the recursion). When it's a different client (i.e. a
|
||||
// remote BackspaceApiClient), we delegate to that client's identical
|
||||
// method, which will see itself as `this` and execute the request.
|
||||
// This keeps the federation event's sourceInstance equal to the
|
||||
// channel's current ownerHomeInstance — required by receiver authority
|
||||
// checks (see docs/systems/federation.md and the kick-authority test).
|
||||
updateMetadata: (channelId, body) => {
|
||||
const target = getApiForOrigin(getOwnerInstanceForDm(channelId));
|
||||
if (target !== this) return target.dm.updateMetadata(channelId, body);
|
||||
return request<DmChannel>('PATCH', `/dm/${channelId}`, body);
|
||||
},
|
||||
kickMember: (channelId, targetUserId) => {
|
||||
const target = getApiForOrigin(getOwnerInstanceForDm(channelId));
|
||||
if (target !== this) return target.dm.kickMember(channelId, targetUserId);
|
||||
return request<{ success: boolean }>('DELETE', `/dm/${channelId}/members/${targetUserId}`);
|
||||
},
|
||||
transferOwnership: (channelId, newOwnerId) => {
|
||||
const target = getApiForOrigin(getOwnerInstanceForDm(channelId));
|
||||
if (target !== this) return target.dm.transferOwnership(channelId, newOwnerId);
|
||||
return request<DmChannel>('POST', `/dm/${channelId}/transfer`, { newOwnerId });
|
||||
},
|
||||
spaceInvite: (body) =>
|
||||
request<SpaceInviteResponse>('POST', '/dm/space-invite', body),
|
||||
};
|
||||
|
||||
@@ -32,7 +32,48 @@ vi.mock('../stores/authStore', () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
// Spy-able getApiForOrigin: returns a remote-flavoured client when given a
|
||||
// non-empty origin, the home stub otherwise. Used to assert which origin
|
||||
// owner-only DM calls route to. We mock the resolver module directly so the
|
||||
// real api/client.ts (which imports it) ends up calling our spy at runtime.
|
||||
// Hoisted via vi.hoisted so the factory below — which is itself hoisted
|
||||
// above the rest of the file — can reference the spies without TDZ errors.
|
||||
const { remoteClient, homeClient, mockGetApiForOrigin } = vi.hoisted(() => {
|
||||
const remote = {
|
||||
dm: {
|
||||
updateMetadata: vi.fn().mockResolvedValue({}),
|
||||
kickMember: vi.fn().mockResolvedValue({}),
|
||||
transferOwnership: vi.fn().mockResolvedValue({}),
|
||||
sendMessage: vi.fn().mockResolvedValue({}),
|
||||
},
|
||||
};
|
||||
const home = {
|
||||
dm: {
|
||||
updateMetadata: vi.fn().mockResolvedValue({}),
|
||||
kickMember: vi.fn().mockResolvedValue({}),
|
||||
transferOwnership: vi.fn().mockResolvedValue({}),
|
||||
sendMessage: vi.fn().mockResolvedValue({}),
|
||||
},
|
||||
};
|
||||
return {
|
||||
remoteClient: remote,
|
||||
homeClient: home,
|
||||
mockGetApiForOrigin: vi.fn((origin: string) =>
|
||||
origin ? (remote as never) : (home as never),
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('./crossStoreResolvers', async () => {
|
||||
const actual = await vi.importActual<typeof import('./crossStoreResolvers')>('./crossStoreResolvers');
|
||||
return {
|
||||
...actual,
|
||||
getApiForOrigin: mockGetApiForOrigin,
|
||||
};
|
||||
});
|
||||
|
||||
import { useSpaceStore, getOwnerInstanceForDm, getChannelOrigin } from '../stores/spaceStore';
|
||||
import { api } from '../api/client';
|
||||
|
||||
const baseDm = {
|
||||
id: 'dm-1',
|
||||
@@ -49,6 +90,7 @@ const baseDm = {
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
useSpaceStore.getState().reset();
|
||||
});
|
||||
|
||||
@@ -85,3 +127,71 @@ describe('getOwnerInstanceForDm — helper', () => {
|
||||
expect(getOwnerInstanceForDm('dm-1')).toBe('https://orbit.test');
|
||||
});
|
||||
});
|
||||
|
||||
describe('group DM owner routing — api.dm.* (Task 5.2)', () => {
|
||||
it('baseline (no transfer): owner-only ops route to home (empty origin)', async () => {
|
||||
useSpaceStore.setState({ dmChannels: [{ ...baseDm }] });
|
||||
|
||||
await api.dm.updateMetadata('dm-1', { name: 'X' });
|
||||
|
||||
expect(mockGetApiForOrigin).toHaveBeenCalledWith('');
|
||||
// Home client is returned for empty origin; the singleton delegates to it,
|
||||
// and the spy on homeClient.dm.updateMetadata records the call.
|
||||
expect(homeClient.dm.updateMetadata).toHaveBeenCalledWith('dm-1', { name: 'X' });
|
||||
expect(remoteClient.dm.updateMetadata).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('after transfer: api.dm.updateMetadata routes to new owner instance', async () => {
|
||||
useSpaceStore.setState({
|
||||
dmChannels: [{ ...baseDm, ownerHomeInstance: 'https://orbit.test' }],
|
||||
});
|
||||
|
||||
await api.dm.updateMetadata('dm-1', { name: 'Renamed' });
|
||||
|
||||
expect(mockGetApiForOrigin).toHaveBeenCalledWith('https://orbit.test');
|
||||
expect(remoteClient.dm.updateMetadata).toHaveBeenCalledWith('dm-1', { name: 'Renamed' });
|
||||
});
|
||||
|
||||
it('after transfer: api.dm.kickMember routes to new owner instance', async () => {
|
||||
useSpaceStore.setState({
|
||||
dmChannels: [{ ...baseDm, ownerHomeInstance: 'https://orbit.test' }],
|
||||
});
|
||||
|
||||
await api.dm.kickMember('dm-1', 'target-user');
|
||||
|
||||
expect(mockGetApiForOrigin).toHaveBeenCalledWith('https://orbit.test');
|
||||
expect(remoteClient.dm.kickMember).toHaveBeenCalledWith('dm-1', 'target-user');
|
||||
});
|
||||
|
||||
it('after transfer: api.dm.transferOwnership routes to new owner instance', async () => {
|
||||
useSpaceStore.setState({
|
||||
dmChannels: [{ ...baseDm, ownerHomeInstance: 'https://orbit.test' }],
|
||||
});
|
||||
|
||||
await api.dm.transferOwnership('dm-1', 'next-owner');
|
||||
|
||||
expect(mockGetApiForOrigin).toHaveBeenCalledWith('https://orbit.test');
|
||||
expect(remoteClient.dm.transferOwnership).toHaveBeenCalledWith('dm-1', 'next-owner');
|
||||
});
|
||||
|
||||
it('non-owner-only op (sendMessage) is unaffected by ownerHomeInstance', async () => {
|
||||
// Owner routing is opt-in per method — sendMessage on the singleton api
|
||||
// must NOT consult ownerHomeInstance. It uses the channel's pinned origin
|
||||
// resolved by the caller (via getChannelOrigin), not the owner instance.
|
||||
useSpaceStore.setState({
|
||||
dmChannels: [{ ...baseDm, ownerHomeInstance: 'https://orbit.test' }],
|
||||
});
|
||||
|
||||
// Mock fetch so sendMessage doesn't try a real network call.
|
||||
const originalFetch = global.fetch;
|
||||
const fetchSpy = vi.fn().mockResolvedValue(new Response(JSON.stringify({}), { status: 200, headers: { 'content-type': 'application/json' } }));
|
||||
global.fetch = fetchSpy as unknown as typeof fetch;
|
||||
try {
|
||||
await api.dm.sendMessage('dm-1', { content: 'hi' });
|
||||
} finally {
|
||||
global.fetch = originalFetch;
|
||||
}
|
||||
|
||||
expect(mockGetApiForOrigin).not.toHaveBeenCalledWith('https://orbit.test');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user