feat(client): api.dm.updateMetadata + kickMember + transferOwnership
This commit is contained in:
@@ -65,6 +65,7 @@ import type {
|
|||||||
SpaceInviteRequest,
|
SpaceInviteRequest,
|
||||||
SpaceInviteResponse,
|
SpaceInviteResponse,
|
||||||
} from '@backspace/shared';
|
} from '@backspace/shared';
|
||||||
|
import { getApiForOrigin, getOwnerInstanceForDm } from '../utils/crossStoreResolvers';
|
||||||
|
|
||||||
export type { FederationPeer, ApprovalRequest, PeeringSubscription, PeeringNotification };
|
export type { FederationPeer, ApprovalRequest, PeeringSubscription, PeeringNotification };
|
||||||
|
|
||||||
@@ -174,6 +175,23 @@ export class BackspaceApiClient {
|
|||||||
deleteMessage: (id: string) => Promise<{ success: boolean }>;
|
deleteMessage: (id: string) => Promise<{ success: boolean }>;
|
||||||
addMember: (dmChannelId: string, data: AddDmMemberRequest) => Promise<DmChannel>;
|
addMember: (dmChannelId: string, data: AddDmMemberRequest) => Promise<DmChannel>;
|
||||||
leave: (dmChannelId: string) => Promise<{ success: boolean }>;
|
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>;
|
spaceInvite: (body: SpaceInviteRequest) => Promise<SpaceInviteResponse>;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -486,6 +504,30 @@ export class BackspaceApiClient {
|
|||||||
request<DmChannel>('POST', `/dm/${dmChannelId}/members`, data),
|
request<DmChannel>('POST', `/dm/${dmChannelId}/members`, data),
|
||||||
leave: (dmChannelId: string) =>
|
leave: (dmChannelId: string) =>
|
||||||
request<{ success: boolean }>('DELETE', `/dm/${dmChannelId}/members`),
|
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) =>
|
spaceInvite: (body) =>
|
||||||
request<SpaceInviteResponse>('POST', '/dm/space-invite', 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 { useSpaceStore, getOwnerInstanceForDm, getChannelOrigin } from '../stores/spaceStore';
|
||||||
|
import { api } from '../api/client';
|
||||||
|
|
||||||
const baseDm = {
|
const baseDm = {
|
||||||
id: 'dm-1',
|
id: 'dm-1',
|
||||||
@@ -49,6 +90,7 @@ const baseDm = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
useSpaceStore.getState().reset();
|
useSpaceStore.getState().reset();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -85,3 +127,71 @@ describe('getOwnerInstanceForDm — helper', () => {
|
|||||||
expect(getOwnerInstanceForDm('dm-1')).toBe('https://orbit.test');
|
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