diff --git a/apps/web/app/api/routes/[id]/route.ts b/apps/web/app/api/routes/[id]/route.ts new file mode 100644 index 0000000..d035e6c --- /dev/null +++ b/apps/web/app/api/routes/[id]/route.ts @@ -0,0 +1,10 @@ +const API_URL = process.env.API_URL ?? 'http://localhost:3001'; + +export async function DELETE( + _req: Request, + { params }: { params: Promise<{ id: string }> }, +) { + const { id } = await params; + const res = await fetch(`${API_URL}/routes/${id}`, { method: 'DELETE' }); + return new Response(null, { status: res.status }); +} diff --git a/apps/web/app/api/routes/route.ts b/apps/web/app/api/routes/route.ts new file mode 100644 index 0000000..b595330 --- /dev/null +++ b/apps/web/app/api/routes/route.ts @@ -0,0 +1,19 @@ +const API_URL = process.env.API_URL ?? 'http://localhost:3001'; + +export async function GET(req: Request) { + const { searchParams } = new URL(req.url); + const url = new URL(`${API_URL}/routes`); + searchParams.forEach((v, k) => url.searchParams.set(k, v)); + const res = await fetch(url, { cache: 'no-store' }); + return Response.json(await res.json(), { status: res.status }); +} + +export async function POST(req: Request) { + const body = await req.json(); + const res = await fetch(`${API_URL}/routes`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + return Response.json(await res.json(), { status: res.status }); +} diff --git a/apps/web/app/groups/RouteManager.test.tsx b/apps/web/app/groups/RouteManager.test.tsx new file mode 100644 index 0000000..99c8783 --- /dev/null +++ b/apps/web/app/groups/RouteManager.test.tsx @@ -0,0 +1,77 @@ +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { RouteManager } from './RouteManager'; + +const groups = [ + { id: 'grp_1', name: 'Alpha', platform: 'whatsapp' }, + { id: 'grp_2', name: 'Beta', platform: 'whatsapp' }, +]; + +const routes = [ + { + id: 'rt_1', + sourceGroupId: 'grp_1', + targetGroupId: 'grp_2', + sourceGroup: { name: 'Alpha' }, + targetGroup: { name: 'Beta' }, + }, +]; + +let fetchSpy: jest.SpyInstance; + +beforeEach(() => { + fetchSpy = jest.spyOn(global, 'fetch'); +}); + +afterEach(() => { + jest.restoreAllMocks(); +}); + +describe('RouteManager', () => { + it('renders existing routes with source → target names', () => { + render(); + expect(screen.getByText('Alpha')).toBeInTheDocument(); + expect(screen.getByText('Beta')).toBeInTheDocument(); + }); + + it('renders two group select dropdowns for adding a route', () => { + render(); + expect(screen.getAllByRole('combobox')).toHaveLength(2); + }); + + it('calls POST /api/routes when Add route is submitted', async () => { + fetchSpy.mockResolvedValueOnce( + new Response( + JSON.stringify({ id: 'rt_new', sourceGroupId: 'grp_1', targetGroupId: 'grp_2', sourceGroup: { name: 'Alpha' }, targetGroup: { name: 'Beta' } }), + { status: 201, headers: { 'Content-Type': 'application/json' } }, + ), + ); + render(); + const [sourceSelect, targetSelect] = screen.getAllByRole('combobox'); + fireEvent.change(sourceSelect, { target: { value: 'grp_1' } }); + fireEvent.change(targetSelect, { target: { value: 'grp_2' } }); + fireEvent.click(screen.getByRole('button', { name: /add route/i })); + await waitFor(() => { + expect(fetchSpy).toHaveBeenCalledWith( + '/api/routes', + expect.objectContaining({ method: 'POST' }), + ); + }); + }); + + it('shows a delete button for each existing route', () => { + render(); + expect(screen.getByRole('button', { name: /delete/i })).toBeInTheDocument(); + }); + + it('calls DELETE /api/routes/:id when a route is deleted', async () => { + fetchSpy.mockResolvedValueOnce(new Response(null, { status: 204 })); + render(); + fireEvent.click(screen.getByRole('button', { name: /delete/i })); + await waitFor(() => { + expect(fetchSpy).toHaveBeenCalledWith( + '/api/routes/rt_1', + expect.objectContaining({ method: 'DELETE' }), + ); + }); + }); +}); diff --git a/apps/web/app/groups/RouteManager.tsx b/apps/web/app/groups/RouteManager.tsx new file mode 100644 index 0000000..1500c8b --- /dev/null +++ b/apps/web/app/groups/RouteManager.tsx @@ -0,0 +1,123 @@ +'use client'; + +import { useState } from 'react'; + +interface Group { + id: string; + name: string; + platform: string; +} + +interface Route { + id: string; + sourceGroupId: string; + targetGroupId: string; + sourceGroup: { name: string }; + targetGroup: { name: string }; +} + +export function RouteManager({ + groups, + initialRoutes, +}: { + groups: Group[]; + initialRoutes: Route[]; +}) { + const [routes, setRoutes] = useState(initialRoutes); + const [sourceId, setSourceId] = useState(''); + const [targetId, setTargetId] = useState(''); + const [busy, setBusy] = useState(false); + + async function addRoute() { + if (!sourceId || !targetId) return; + setBusy(true); + try { + const res = await fetch('/api/routes', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ sourceGroupId: sourceId, targetGroupId: targetId }), + }); + if (!res.ok) return; + const created: Route = await res.json(); + setRoutes((prev) => [created, ...prev]); + setSourceId(''); + setTargetId(''); + } finally { + setBusy(false); + } + } + + async function deleteRoute(id: string) { + const res = await fetch(`/api/routes/${id}`, { method: 'DELETE' }); + if (res.ok) setRoutes((prev) => prev.filter((r) => r.id !== id)); + } + + return ( +
+
+

Active sync routes

+ {routes.length === 0 ? ( +

No routes configured.

+ ) : ( +
    + {routes.map((route) => ( +
  • + + {route.sourceGroup.name} + + {route.targetGroup.name} + + +
  • + ))} +
+ )} +
+ +
+

Add route

+
+ + + + +
+
+
+ ); +} diff --git a/apps/web/app/groups/page.tsx b/apps/web/app/groups/page.tsx new file mode 100644 index 0000000..7945f75 --- /dev/null +++ b/apps/web/app/groups/page.tsx @@ -0,0 +1,40 @@ +import { RouteManager } from './RouteManager'; + +interface Group { + id: string; + name: string; + platform: string; +} + +interface Route { + id: string; + sourceGroupId: string; + targetGroupId: string; + sourceGroup: { name: string }; + targetGroup: { name: string }; +} + +async function fetchJson(url: string): Promise { + try { + const res = await fetch(url, { cache: 'no-store' }); + if (!res.ok) return null; + return res.json(); + } catch { + return null; + } +} + +export default async function GroupsPage() { + const apiUrl = process.env.API_URL ?? 'http://localhost:3001'; + const [groups, routes] = await Promise.all([ + fetchJson(`${apiUrl}/groups`), + fetchJson(`${apiUrl}/routes`), + ]); + + return ( +
+

Groups & Routes

+ +
+ ); +} diff --git a/apps/web/jest.config.js b/apps/web/jest.config.js index 2d97230..6bed471 100644 --- a/apps/web/jest.config.js +++ b/apps/web/jest.config.js @@ -4,6 +4,8 @@ const createJestConfig = nextJest({ dir: './' }); module.exports = createJestConfig({ setupFilesAfterEnv: ['/jest.setup.ts'], - testEnvironment: 'jest-environment-jsdom', + // Custom environment that extends jest-environment-jsdom and polyfills the + // native Node 18+ Fetch API so tests can use jest.spyOn(global, 'fetch'). + testEnvironment: '/jest.environment.js', testMatch: ['**/*.test.tsx', '**/*.test.ts'], }); diff --git a/apps/web/jest.environment.js b/apps/web/jest.environment.js new file mode 100644 index 0000000..4512f9c --- /dev/null +++ b/apps/web/jest.environment.js @@ -0,0 +1,22 @@ +/** + * Custom Jest environment that extends jest-environment-jsdom and polyfills + * the Fetch API (fetch, Request, Response, Headers) from Node 18+'s native + * implementation. This lets tests spy on `global.fetch` with jest.spyOn. + */ +const JSDOMEnvironment = require('jest-environment-jsdom').default; + +class CustomJSDOMEnvironment extends JSDOMEnvironment { + async setup() { + await super.setup(); + + // Node 18+ exposes fetch on globalThis; copy it into the jsdom sandbox. + if (typeof globalThis.fetch === 'function') { + this.global.fetch = globalThis.fetch.bind(globalThis); + this.global.Request = globalThis.Request; + this.global.Response = globalThis.Response; + this.global.Headers = globalThis.Headers; + } + } +} + +module.exports = CustomJSDOMEnvironment; diff --git a/apps/web/jest.setup.ts b/apps/web/jest.setup.ts index 7b0828b..e9cf2b1 100644 --- a/apps/web/jest.setup.ts +++ b/apps/web/jest.setup.ts @@ -1 +1,7 @@ import '@testing-library/jest-dom'; +import { configure } from '@testing-library/react'; + +// Exclude