Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 107 additions & 4 deletions frontend/src/v2/__tests__/V2ConnectorsPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import V2ConnectorsPage from '../components/V2ConnectorsPage';
import V2ConnectorsPage, { installableLifecyclePath } from '../components/V2ConnectorsPage';
import { AuthContext } from '../../context/AuthContext';

jest.mock('axios', () => {
Expand Down Expand Up @@ -49,6 +49,7 @@ const connectors = [
},
{
_id: 'i-live',
installationId: 'install-telegram-u1',
type: 'telegram',
status: 'connected',
config: { chatTitle: 'Rewire crew', liveRelay: false },
Expand Down Expand Up @@ -153,20 +154,122 @@ describe('V2ConnectorsPage', () => {
expect(options).not.toContain('Town Square');
});

it('creates a telegram connector for the selected pod', async () => {
it('installs Telegram for the selected pod', async () => {
mockGets([]);
axios.post.mockResolvedValue({ data: { integration: { _id: 'new' } } });
renderPage();
await screen.findByText(/Link a channel/);
fireEvent.click(screen.getByRole('button', { name: /Telegram/ }));
fireEvent.click(screen.getByText('New Telegram connector'));
await waitFor(() => expect(axios.post).toHaveBeenCalledWith(
'/api/integrations',
{ podId: 'p1', type: 'telegram', config: {} },
'/api/installables/telegram/install',
{ podId: 'p1' },
expect.anything(),
));
});

it('keeps the add form open while an install claim is in progress', async () => {
mockGets([]);
axios.post.mockResolvedValue({ data: { status: 'installing' } });
renderPage();
await screen.findByText(/Link a channel/);
fireEvent.click(screen.getByRole('button', { name: /Telegram/ }));
fireEvent.click(screen.getByText('New Telegram connector'));

expect(await screen.findByText('Still setting up — try again in a moment.')).toBeInTheDocument();
expect(screen.getByLabelText('Pod to bridge')).toBeInTheDocument();
});

it('names the pending pod returned with a 202 install response', async () => {
mockGets([]);
axios.post.mockResolvedValue({ data: { status: 'installing', boundPodId: 'p1' } });
renderPage();
await screen.findByText(/Link a channel/);
fireEvent.click(screen.getByRole('button', { name: /Telegram/ }));
fireEvent.click(screen.getByText('New Telegram connector'));

expect(await screen.findByText('Still setting up for Rewire Live Demo — try again in a moment.')).toBeInTheDocument();
expect(screen.getByLabelText('Pod to bridge')).toBeInTheDocument();
});

it('keeps the add form open when the server reports an install lock', async () => {
mockGets([]);
axios.post.mockRejectedValue({ response: { status: 409, data: { code: 'install_in_progress' } } });
renderPage();
await screen.findByText(/Link a channel/);
fireEvent.click(screen.getByRole('button', { name: /Telegram/ }));
fireEvent.click(screen.getByText('New Telegram connector'));

expect(await screen.findByText('Still setting up — try again in a moment.')).toBeInTheDocument();
expect(screen.getByLabelText('Pod to bridge')).toBeInTheDocument();
});

it('names the pending pod when the server reports an install lock', async () => {
mockGets([]);
axios.post.mockRejectedValue({
response: { status: 409, data: { code: 'install_in_progress', boundPodId: 'p1' } },
});
renderPage();
await screen.findByText(/Link a channel/);
fireEvent.click(screen.getByRole('button', { name: /Telegram/ }));
fireEvent.click(screen.getByText('New Telegram connector'));

expect(await screen.findByText('Still setting up for Rewire Live Demo — try again in a moment.')).toBeInTheDocument();
expect(screen.getByLabelText('Pod to bridge')).toBeInTheDocument();
});

it('names the existing pod when the installable is already bound elsewhere', async () => {
mockGets([]);
axios.post.mockRejectedValue({
response: { status: 409, data: { code: 'already_installed', boundPodId: 'p1' } },
});
renderPage();
await screen.findByText(/Link a channel/);
fireEvent.click(screen.getByRole('button', { name: /Telegram/ }));
fireEvent.click(screen.getByText('New Telegram connector'));

expect(await screen.findByText(/bound to Rewire Live Demo/)).toBeInTheDocument();
expect(screen.getByLabelText('Pod to bridge')).toBeInTheDocument();
});

it('disconnects an installable Telegram connector through its lifecycle verb', async () => {
mockGets();
axios.delete.mockResolvedValue({ data: { status: 'uninstalled' } });
renderPage();
fireEvent.click(await screen.findByRole('button', { name: 'Manage' }));
fireEvent.click(screen.getByRole('button', { name: 'Disconnect' }));
fireEvent.click(screen.getByRole('button', { name: /Really disconnect/ }));
await waitFor(() => expect(axios.delete).toHaveBeenCalledWith(
'/api/installables/telegram/install',
expect.anything(),
));
});

it('derives the installable lifecycle target from the connector row type', () => {
expect(installableLifecyclePath('slack')).toBe('/api/installables/slack/install');
});

it('keeps a legacy Telegram connector disconnectable through the legacy route', async () => {
mockGets([{ ...connectors[1], installationId: undefined }]);
axios.patch.mockResolvedValue({ data: {} });
renderPage();
fireEvent.click(await screen.findByRole('button', { name: 'Manage' }));
fireEvent.click(screen.getByRole('button', { name: 'Disconnect' }));
fireEvent.click(screen.getByRole('button', { name: /Really disconnect/ }));
await waitFor(() => expect(axios.patch).toHaveBeenCalledWith(
'/api/integrations/i-live',
{ isActive: false },
expect.anything(),
));
});

it('does not offer a second Telegram install while an installable row exists', async () => {
mockGets();
renderPage();
await screen.findByText('Your channels');
expect(screen.queryByRole('button', { name: /Telegram/ })).toBeNull();
});

it('SOON platforms are not buttons', async () => {
mockGets([]);
renderPage();
Expand Down
76 changes: 71 additions & 5 deletions frontend/src/v2/components/V2ConnectorsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,26 @@ interface ConnectorConfig {

interface Connector {
_id: string;
// Installable-backed channel rows carry their parent binding. Older
// integrations remain managed through the legacy route until migrated.
installationId?: string;
type: string;
status: string;
updatedAt?: string;
config?: ConnectorConfig;
podId?: { _id: string; name?: string } | string | null;
}

interface InstallResponse {
status?: 'active' | 'installing' | 'activating' | 'uninstalling';
boundPodId?: string;
}

interface InstallErrorResponse {
code?: string;
boundPodId?: string;
}

const TYPE_LABELS: Record<string, string> = {
telegram: 'Telegram',
discord: 'Discord',
Expand All @@ -53,10 +66,30 @@ const ADD_PLATFORMS: { type: string; enabled: boolean }[] = [

const BOT_HANDLE = process.env.REACT_APP_TELEGRAM_BOT_HANDLE || '';

// The route is keyed by the installable identity, which is the connector
// provider type in this Phase 1 projection. Do not pin this to Telegram: a
// future installable-backed provider must revoke its own parent.
export const installableLifecyclePath = (type: string): string => (
`/api/installables/${encodeURIComponent(type)}/install`
);

const installErrorResponse = (error: unknown): { status?: number; data?: InstallErrorResponse } => (
(error as { response?: { status?: number; data?: InstallErrorResponse } })?.response || {}
);

const podName = (c: Connector): string => (
typeof c.podId === 'object' && c.podId ? (c.podId.name || 'Untitled pod') : 'Untitled pod'
);

const boundPodName = (boundPodId: string | undefined, connectors: Connector[], pods: V2Pod[]): string => {
const existing = connectors.find((connector) => {
const podId = typeof connector.podId === 'object' ? connector.podId?._id : connector.podId;
return String(podId) === String(boundPodId);
});
const boundPod = pods.find((pod) => String(pod._id) === String(boundPodId));
return existing ? podName(existing) : (boundPod?.name || 'another pod');
};

// Codes are 32 hex now (128-bit) — grouped in 4s, wrapping; legacy short
// codes flow through the same grouping (spec §2.3 step 2).
const groupCode = (code: string): string => (code.match(/.{1,4}/g) || [code]).join(' ');
Expand Down Expand Up @@ -137,12 +170,34 @@ const V2ConnectorsPage: React.FC = () => {
if (!newPodId || creating) return;
setCreating(true);
setError(null);
const installInProgressMessage = (boundPodId?: string): string => (
boundPodId
? t('connectors.installInProgressForPod', {
defaultValue: 'Still setting up for {{pod}} — try again in a moment.',
pod: boundPodName(boundPodId, connectors, pods),
})
: t('connectors.installInProgress', { defaultValue: 'Still setting up — try again in a moment.' })
);
try {
await api.post('/api/integrations', { podId: newPodId, type: 'telegram', config: {} });
const result = await api.post<InstallResponse>('/api/installables/telegram/install', { podId: newPodId });
if (result.status && result.status !== 'active') {
setError(installInProgressMessage(result.boundPodId));
return;
}
setAdding(false);
await load();
} catch {
setError(t('connectors.createError', { defaultValue: 'Could not create the connector.' }));
} catch (error) {
const response = installErrorResponse(error);
if (response.status === 409 && response.data?.code === 'install_in_progress') {
setError(installInProgressMessage(response.data.boundPodId));
} else if (response.status === 409 && response.data?.code === 'already_installed') {
setError(t('connectors.alreadyBound', {
defaultValue: 'Your Telegram channel is bound to {{pod}}. Disconnect it to bind a different pod.',
pod: boundPodName(response.data.boundPodId, connectors, pods),
}));
} else {
setError(t('connectors.createError', { defaultValue: 'Could not create the connector.' }));
}
} finally {
setCreating(false);
}
Expand Down Expand Up @@ -179,7 +234,14 @@ const V2ConnectorsPage: React.FC = () => {
const disconnect = async (c: Connector) => {
setBusyId(c._id);
try {
await api.patch(`/api/integrations/${c._id}`, { isActive: false });
if (c.installationId) {
await api.del(installableLifecyclePath(c.type));
} else {
// The installable verb owns lifecycle for new Telegram bindings. Keep
// older direct integrations disconnectable while their migration is
// still an explicit, separate change.
await api.patch(`/api/integrations/${c._id}`, { isActive: false });
}
setConfirmDisconnect(null);
setManageId(null);
await load();
Expand Down Expand Up @@ -332,9 +394,13 @@ const V2ConnectorsPage: React.FC = () => {
);
};

const hasTelegramInstallation = connectors.some(
(connector) => connector.type === 'telegram' && Boolean(connector.installationId),
);

const addTiles = (
<div className="v2-connector-add__tiles">
{ADD_PLATFORMS.map((p) => (p.enabled ? (
{ADD_PLATFORMS.map((p) => (p.enabled && !(p.type === 'telegram' && hasTelegramInstallation) ? (
<button key={p.type} type="button" className="v2-connector-add__tile" onClick={() => setAdding(true)}>
<span className={`v2-connector__tile v2-connector__tile--${p.type}`}><PlatformGlyph type={p.type} /></span>
<span>{TYPE_LABELS[p.type]}</span>
Expand Down
Loading