mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
fix(agent-world): fix group join feedback + leave-for-members guard (#3840)
This commit is contained in:
@@ -1,12 +1,14 @@
|
||||
/**
|
||||
* Tests for MessagingSection — gated DMs "coming soon" state + basic render.
|
||||
* Tests for MessagingSection — gated DMs "coming soon" state + basic render,
|
||||
* and group membership-aware Join/Leave button behaviour.
|
||||
*
|
||||
* We mock the apiClient so no actual RPC calls are made.
|
||||
* We mock the apiClient and fetchWalletStatus so no actual RPC calls are made.
|
||||
*/
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import { fetchWalletStatus } from '../../services/walletApi';
|
||||
import { apiClient } from '../AgentWorldShell';
|
||||
import MessagingSection from './MessagingSection';
|
||||
|
||||
@@ -141,8 +143,25 @@ vi.mock('../hooks/useTinyplaceStream', () => ({
|
||||
useTinyplaceStream: (streamId?: string) => mockUseTinyplaceStream(streamId),
|
||||
}));
|
||||
|
||||
// ── Mock fetchWalletStatus ────────────────────────────────────────────────────
|
||||
|
||||
vi.mock('../../services/walletApi', () => ({ fetchWalletStatus: vi.fn() }));
|
||||
|
||||
/** Helper: configure wallet mock to return the given agent ID (Solana address). */
|
||||
function setWallet(agentId: string | null) {
|
||||
vi.mocked(fetchWalletStatus).mockResolvedValue(
|
||||
agentId
|
||||
? ({ accounts: [{ chain: 'solana', address: agentId }] } as unknown as Awaited<
|
||||
ReturnType<typeof fetchWalletStatus>
|
||||
>)
|
||||
: ({ accounts: [] } as unknown as Awaited<ReturnType<typeof fetchWalletStatus>>)
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Default: wallet not connected — groups.list membership call returns [].
|
||||
setWallet(null);
|
||||
});
|
||||
|
||||
// ── DMs panel (E2E enabled) ───────────────────────────────────────────────────
|
||||
@@ -426,18 +445,20 @@ describe('membership actions', () => {
|
||||
expect(apiClient.broadcasts.subscribe).toHaveBeenCalledWith('bc-1');
|
||||
});
|
||||
|
||||
test('group Leave calls groups.leave with the group id', async () => {
|
||||
vi.mocked(apiClient.groups.list).mockResolvedValue([
|
||||
{
|
||||
groupId: 'g-1',
|
||||
name: 'Builders',
|
||||
membershipPolicy: 'open',
|
||||
memberCount: 5,
|
||||
membershipEpoch: 1,
|
||||
createdBy: 'someone',
|
||||
createdAt: '2026-01-01T00:00:00Z',
|
||||
},
|
||||
]);
|
||||
test('group Leave calls groups.leave when user is a member', async () => {
|
||||
const group = {
|
||||
groupId: 'g-1',
|
||||
name: 'Builders',
|
||||
membershipPolicy: 'open',
|
||||
memberCount: 5,
|
||||
membershipEpoch: 1,
|
||||
createdBy: 'someone',
|
||||
createdAt: '2026-01-01T00:00:00Z',
|
||||
};
|
||||
// Wallet connected — user is a member of g-1.
|
||||
setWallet('agent-abc');
|
||||
// Both public and membership queries return the same group so button shows "Leave".
|
||||
vi.mocked(apiClient.groups.list).mockResolvedValue([group]);
|
||||
render(<MessagingSection />);
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Groups' }));
|
||||
await screen.findByText('Builders');
|
||||
@@ -446,6 +467,139 @@ describe('membership actions', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── Group membership-aware Join / Leave ────────────────────────────────────────
|
||||
|
||||
describe('group membership-aware button rendering', () => {
|
||||
const groupA = {
|
||||
groupId: 'g-a',
|
||||
name: 'Alpha',
|
||||
membershipPolicy: 'open',
|
||||
memberCount: 2,
|
||||
membershipEpoch: 1,
|
||||
createdBy: 'owner',
|
||||
createdAt: '2026-01-01T00:00:00Z',
|
||||
};
|
||||
const groupB = {
|
||||
groupId: 'g-b',
|
||||
name: 'Beta',
|
||||
membershipPolicy: 'open',
|
||||
memberCount: 4,
|
||||
membershipEpoch: 1,
|
||||
createdBy: 'owner',
|
||||
createdAt: '2026-01-01T00:00:00Z',
|
||||
};
|
||||
|
||||
async function openGroups() {
|
||||
render(<MessagingSection />);
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Groups' }));
|
||||
await screen.findByText('Alpha');
|
||||
}
|
||||
|
||||
test('shows Join for non-member groups and Leave for member groups', async () => {
|
||||
setWallet('agent-xyz');
|
||||
// Public returns both; membership query returns only g-a (user is a member of Alpha only).
|
||||
vi.mocked(apiClient.groups.list)
|
||||
.mockResolvedValueOnce([groupA, groupB]) // public query
|
||||
.mockResolvedValueOnce([groupA]); // membership query (member=agent-xyz)
|
||||
|
||||
await openGroups();
|
||||
await screen.findByText('Beta');
|
||||
|
||||
// Alpha: user is a member → "Leave" button
|
||||
const alphaCard = screen.getByText('Alpha').closest('div')!;
|
||||
expect(alphaCard.querySelector('button[disabled]') ?? alphaCard).toBeTruthy();
|
||||
// Find buttons via accessible name
|
||||
const allLeave = screen.getAllByRole('button', { name: 'Leave' });
|
||||
const allJoin = screen.getAllByRole('button', { name: 'Join' });
|
||||
expect(allLeave).toHaveLength(1); // only Alpha
|
||||
expect(allJoin).toHaveLength(1); // only Beta
|
||||
});
|
||||
|
||||
test('Leave button is absent for non-member groups (no wallet)', async () => {
|
||||
// No wallet → membership list is empty → all groups show Join.
|
||||
setWallet(null);
|
||||
vi.mocked(apiClient.groups.list).mockResolvedValue([groupA, groupB]);
|
||||
|
||||
await openGroups();
|
||||
await screen.findByText('Beta');
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'Leave' })).not.toBeInTheDocument();
|
||||
expect(screen.getAllByRole('button', { name: 'Join' })).toHaveLength(2);
|
||||
});
|
||||
|
||||
test('Join calls groups.join and after refetch button flips to Leave', async () => {
|
||||
setWallet('agent-xyz');
|
||||
// First render: user is not a member of g-a.
|
||||
vi.mocked(apiClient.groups.list)
|
||||
.mockResolvedValueOnce([groupA]) // public
|
||||
.mockResolvedValueOnce([]) // membership — not a member yet
|
||||
// After join + refetch:
|
||||
.mockResolvedValueOnce([groupA]) // public
|
||||
.mockResolvedValueOnce([groupA]); // membership — now a member
|
||||
|
||||
vi.mocked(apiClient.groups.join).mockResolvedValue(undefined);
|
||||
|
||||
render(<MessagingSection />);
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Groups' }));
|
||||
await screen.findByText('Alpha');
|
||||
|
||||
// Button shows Join before joining.
|
||||
expect(screen.getByRole('button', { name: 'Join' })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Leave' })).not.toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Join' }));
|
||||
expect(apiClient.groups.join).toHaveBeenCalledWith('g-a');
|
||||
|
||||
// After re-fetch, button should flip to Leave.
|
||||
expect(await screen.findByRole('button', { name: 'Leave' })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Join' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('Leave calls groups.leave and after refetch button flips to Join', async () => {
|
||||
setWallet('agent-xyz');
|
||||
// First render: user IS a member of g-a.
|
||||
vi.mocked(apiClient.groups.list)
|
||||
.mockResolvedValueOnce([groupA]) // public
|
||||
.mockResolvedValueOnce([groupA]) // membership — is a member
|
||||
// After leave + refetch:
|
||||
.mockResolvedValueOnce([groupA]) // public
|
||||
.mockResolvedValueOnce([]); // membership — no longer a member
|
||||
|
||||
vi.mocked(apiClient.groups.leave).mockResolvedValue(undefined);
|
||||
|
||||
render(<MessagingSection />);
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Groups' }));
|
||||
await screen.findByText('Alpha');
|
||||
|
||||
// Button shows Leave (user is a member).
|
||||
expect(await screen.findByRole('button', { name: 'Leave' })).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Leave' }));
|
||||
expect(apiClient.groups.leave).toHaveBeenCalledWith('g-a');
|
||||
|
||||
// After re-fetch, button should flip to Join.
|
||||
expect(await screen.findByRole('button', { name: 'Join' })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Leave' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('membership dual-fetch passes member param to groups.list', async () => {
|
||||
setWallet('agent-solana-123');
|
||||
vi.mocked(apiClient.groups.list).mockResolvedValue([]);
|
||||
|
||||
render(<MessagingSection />);
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Groups' }));
|
||||
await screen.findByText(/No groups found/i);
|
||||
|
||||
// The second groups.list call should carry member=<agentId>.
|
||||
const calls = vi.mocked(apiClient.groups.list).mock.calls;
|
||||
const memberCall = calls.find(
|
||||
([p]) => p !== undefined && p !== null && typeof p === 'object' && 'member' in p
|
||||
);
|
||||
expect(memberCall).toBeDefined();
|
||||
expect((memberCall![0] as Record<string, unknown>).member).toBe('agent-solana-123');
|
||||
});
|
||||
});
|
||||
|
||||
// ── Group invite management ─────────────────────────────────────────────────
|
||||
|
||||
describe('group invite management', () => {
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
PaymentRequiredError,
|
||||
type SignalKeyStatus,
|
||||
} from '../../lib/agentworld/invokeApiClient';
|
||||
import { fetchWalletStatus } from '../../services/walletApi';
|
||||
import { apiClient } from '../AgentWorldShell';
|
||||
import { useTinyplaceStream } from '../hooks/useTinyplaceStream';
|
||||
|
||||
@@ -92,6 +93,27 @@ function useAsyncCall<T>(fetcher: () => Promise<T>, deps: unknown[]): AsyncState
|
||||
return state;
|
||||
}
|
||||
|
||||
// ── My agent ID (wallet address) ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Returns the Solana agent ID of the connected wallet, or null when the wallet
|
||||
* is not connected / still loading. Mirrors the pattern in DirectorySection.
|
||||
*/
|
||||
function useMyAgentId(): string | null {
|
||||
const [agentId, setAgentId] = useState<string | null>(null);
|
||||
useEffect(() => {
|
||||
void fetchWalletStatus()
|
||||
.then(status => {
|
||||
const solana = (status.accounts ?? []).find(
|
||||
(a: { chain: string; address?: string }) => a.chain === 'solana'
|
||||
);
|
||||
if (solana?.address) setAgentId(solana.address);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
return agentId;
|
||||
}
|
||||
|
||||
// ── Sub-panels ────────────────────────────────────────────────────────────────
|
||||
|
||||
function LoadingPane() {
|
||||
@@ -323,18 +345,43 @@ function ChannelsPanel() {
|
||||
// ── Groups panel ──────────────────────────────────────────────────────────────
|
||||
|
||||
function GroupsPanel() {
|
||||
const myAgentId = useMyAgentId();
|
||||
const params: GroupQueryParams = { limit: 20 };
|
||||
const { version, busyKey, error: actionError, run } = useRowActions();
|
||||
const state = useAsyncCall(() => apiClient.groups.list(params), [version]);
|
||||
const [invitesGroupId, setInvitesGroupId] = useState<string | null>(null);
|
||||
const [invitesGroupName, setInvitesGroupName] = useState<string>('');
|
||||
const [showRedeem, setShowRedeem] = useState(false);
|
||||
|
||||
if (state.status === 'loading') return <LoadingPane />;
|
||||
if (state.status === 'payment_required') return <PaymentRequiredPane />;
|
||||
if (state.status === 'error') return <ErrorPane message={state.message} />;
|
||||
// Fetch all public groups.
|
||||
const publicState = useAsyncCall(() => apiClient.groups.list(params), [version]);
|
||||
|
||||
const groups: GroupMetadata[] = state.status === 'ok' ? state.data : [];
|
||||
// Fetch groups the user belongs to (uses the `member` query param).
|
||||
// Falls back to empty list when wallet is not yet connected.
|
||||
const myGroupsState = useAsyncCall(
|
||||
() =>
|
||||
myAgentId
|
||||
? apiClient.groups.list({ member: myAgentId })
|
||||
: Promise.resolve([] as GroupMetadata[]),
|
||||
[version, myAgentId]
|
||||
);
|
||||
|
||||
if (publicState.status === 'loading') return <LoadingPane />;
|
||||
if (publicState.status === 'payment_required') return <PaymentRequiredPane />;
|
||||
if (publicState.status === 'error') return <ErrorPane message={publicState.message} />;
|
||||
|
||||
const groups: GroupMetadata[] = publicState.status === 'ok' ? publicState.data : [];
|
||||
|
||||
// Build a Set of group IDs the user is a member of so we can flip Join↔Leave.
|
||||
const myGroupIds = new Set<string>(
|
||||
(myGroupsState.status === 'ok'
|
||||
? Array.isArray(myGroupsState.data)
|
||||
? myGroupsState.data
|
||||
: []
|
||||
: []
|
||||
).map((g: GroupMetadata) => g.groupId)
|
||||
);
|
||||
|
||||
log('[groups] public=%d my=%d agent=%s', groups.length, myGroupIds.size, myAgentId ?? 'none');
|
||||
|
||||
// Show the invites sub-panel for a specific group.
|
||||
if (invitesGroupId) {
|
||||
@@ -384,6 +431,7 @@ function GroupsPanel() {
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
{groups.map(group => {
|
||||
const busy = busyKey === group.groupId;
|
||||
const isMember = myGroupIds.has(group.groupId);
|
||||
return (
|
||||
<div
|
||||
key={group.groupId}
|
||||
@@ -406,16 +454,25 @@ function GroupsPanel() {
|
||||
<span>{group.membershipPolicy}</span>
|
||||
</div>
|
||||
<div className="mt-2 flex gap-1">
|
||||
<RowAction
|
||||
label="Join"
|
||||
disabled={busy}
|
||||
onClick={() => run(group.groupId, () => apiClient.groups.join(group.groupId))}
|
||||
/>
|
||||
<RowAction
|
||||
label="Leave"
|
||||
disabled={busy}
|
||||
onClick={() => run(group.groupId, () => apiClient.groups.leave(group.groupId))}
|
||||
/>
|
||||
{isMember ? (
|
||||
<RowAction
|
||||
label="Leave"
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
log('[groups] leaving group %s', group.groupId);
|
||||
run(group.groupId, () => apiClient.groups.leave(group.groupId));
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<RowAction
|
||||
label="Join"
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
log('[groups] joining group %s', group.groupId);
|
||||
run(group.groupId, () => apiClient.groups.join(group.groupId));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<RowAction
|
||||
label="Invites"
|
||||
disabled={busy}
|
||||
|
||||
@@ -666,6 +666,8 @@ export interface GroupQueryParams {
|
||||
minMembers?: number;
|
||||
maxMembers?: number;
|
||||
limit?: number;
|
||||
/** When set, returns only groups this agent is an active member of. */
|
||||
member?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
// ── Groups invite/role types ────────────────────────────────────────────────
|
||||
|
||||
@@ -1564,6 +1564,32 @@ pub(crate) fn handle_tinyplace_groups_leave(params: Map<String, Value>) -> Contr
|
||||
.signer()
|
||||
.map(|s| s.agent_id())
|
||||
.ok_or_else(|| "tinyplace signer unavailable; cannot leave group".to_string())?;
|
||||
|
||||
// Pre-check membership before calling remove_member so we return a
|
||||
// clear error instead of leaking a raw HTTP 400 from the backend when
|
||||
// the user is not a member.
|
||||
match client.groups.members(&group_id).await {
|
||||
Ok(resp) => {
|
||||
if !resp.members.iter().any(|m| m.agent_id == me) {
|
||||
log::warn!(
|
||||
"{LOG_PREFIX} groups_leave: agent {me} is not a member of {group_id}"
|
||||
);
|
||||
return Err(format!(
|
||||
"you are not a member of group {group_id}; cannot leave"
|
||||
));
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
// If the membership list itself is unavailable (network, 404, etc.)
|
||||
// we log a warning and fall through — the backend will reject if
|
||||
// the agent truly is not a member.
|
||||
log::warn!(
|
||||
"{LOG_PREFIX} groups_leave: membership check failed for {group_id}: {e}; \
|
||||
proceeding with remove_member"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
client
|
||||
.groups
|
||||
.remove_member(&group_id, &me, None)
|
||||
|
||||
@@ -1108,7 +1108,7 @@ fn schema_groups_list() -> ControllerSchema {
|
||||
"List tiny.place groups, optionally filtered by query params (read-only).",
|
||||
inputs: vec![optional_object(
|
||||
"params",
|
||||
"Optional GroupQueryParams (q, tag, tags, membershipPolicy, minMembers, maxMembers, limit).",
|
||||
"Optional GroupQueryParams (q, tag, tags, membershipPolicy, minMembers, maxMembers, member, limit).",
|
||||
)],
|
||||
outputs: vec![json_output(
|
||||
"result",
|
||||
|
||||
Reference in New Issue
Block a user