diff --git a/app/src/agentworld/pages/MessagingSection.test.tsx b/app/src/agentworld/pages/MessagingSection.test.tsx index 8bdf8211f..34d500ead 100644 --- a/app/src/agentworld/pages/MessagingSection.test.tsx +++ b/app/src/agentworld/pages/MessagingSection.test.tsx @@ -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 + >) + : ({ accounts: [] } as unknown as Awaited>) + ); +} + 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(); 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(); + 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(); + 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(); + 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(); + await userEvent.click(screen.getByRole('button', { name: 'Groups' })); + await screen.findByText(/No groups found/i); + + // The second groups.list call should carry member=. + 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).member).toBe('agent-solana-123'); + }); +}); + // ── Group invite management ───────────────────────────────────────────────── describe('group invite management', () => { diff --git a/app/src/agentworld/pages/MessagingSection.tsx b/app/src/agentworld/pages/MessagingSection.tsx index de6f907e3..91034af3b 100644 --- a/app/src/agentworld/pages/MessagingSection.tsx +++ b/app/src/agentworld/pages/MessagingSection.tsx @@ -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(fetcher: () => Promise, 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(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(null); const [invitesGroupName, setInvitesGroupName] = useState(''); const [showRedeem, setShowRedeem] = useState(false); - if (state.status === 'loading') return ; - if (state.status === 'payment_required') return ; - if (state.status === 'error') return ; + // 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 ; + if (publicState.status === 'payment_required') return ; + if (publicState.status === 'error') return ; + + 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( + (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() {
{groups.map(group => { const busy = busyKey === group.groupId; + const isMember = myGroupIds.has(group.groupId); return (
{group.membershipPolicy}
- run(group.groupId, () => apiClient.groups.join(group.groupId))} - /> - run(group.groupId, () => apiClient.groups.leave(group.groupId))} - /> + {isMember ? ( + { + log('[groups] leaving group %s', group.groupId); + run(group.groupId, () => apiClient.groups.leave(group.groupId)); + }} + /> + ) : ( + { + log('[groups] joining group %s', group.groupId); + run(group.groupId, () => apiClient.groups.join(group.groupId)); + }} + /> + )} ) -> 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) diff --git a/src/openhuman/tinyplace/schemas.rs b/src/openhuman/tinyplace/schemas.rs index 219a4a991..b52ee15a1 100644 --- a/src/openhuman/tinyplace/schemas.rs +++ b/src/openhuman/tinyplace/schemas.rs @@ -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",