diff --git a/.gitignore b/.gitignore index 3d67e89d5..9d281a685 100644 --- a/.gitignore +++ b/.gitignore @@ -85,4 +85,5 @@ yarn.lock *.bak storybook-static/ .worktrees/ -.plan/ \ No newline at end of file +.plan/ +.superpowers/ \ No newline at end of file diff --git a/docs/superpowers/plans/2026-03-16-per-repo-webhook-toggle.md b/docs/superpowers/plans/2026-03-16-per-repo-webhook-toggle.md new file mode 100644 index 000000000..e61ab6b21 --- /dev/null +++ b/docs/superpowers/plans/2026-03-16-per-repo-webhook-toggle.md @@ -0,0 +1,1093 @@ +# Per-Repository Webhook Toggle Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Enable users to toggle GitHub webhook registration per repository from the repo node and drawer UI. + +**Architecture:** New public methods on `GitHubWebhookService` and `WebhookManagerService` for single-repo webhook CRUD. Three new Next.js API routes expose these. A `useWebhookAction` hook provides optimistic toggle state to both the repo node action button and drawer section. + +**Tech Stack:** TypeScript, Next.js API routes, React hooks, lucide-react icons, Storybook, Vitest + +**Spec:** `docs/superpowers/specs/2026-03-16-per-repo-webhook-toggle-design.md` + +--- + +## Chunk 1: Service Layer + +### Task 1: Add `registerWebhookForSingleRepo` and `removeWebhookForRepo` to GitHubWebhookService + +**Files:** + +- Modify: `packages/core/src/infrastructure/services/webhook/github-webhook.service.ts` +- Create: `packages/core/src/infrastructure/services/webhook/github-webhook.service.test.ts` + +**Context:** The existing `registerWebhookForRepo()` is private and called from `registerWebhooks()` (bulk). We need to expose single-repo registration as a new public method with a duplicate guard, and add a method to remove a single repo's webhook. All path comparisons must normalize to forward slashes. + +- [ ] **Step 1: Write failing tests for `registerWebhookForSingleRepo`** + +Create the test file. Test three scenarios: (a) registers a webhook for a new repo, (b) no-ops when repo already has a webhook, (c) normalizes paths before comparing. + +```typescript +// packages/core/src/infrastructure/services/webhook/github-webhook.service.test.ts +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { GitHubWebhookService, type ExecFunction } from './github-webhook.service.js'; + +// Minimal mocks for constructor dependencies +const mockFeatureRepo = { list: vi.fn().mockResolvedValue([]) } as any; +const mockGitPrService = { + getRemoteUrl: vi.fn().mockResolvedValue('https://github.com/owner/repo.git'), +} as any; +const mockNotificationService = { notify: vi.fn() } as any; + +function createService(execFn?: ExecFunction) { + const exec = + execFn ?? + vi.fn().mockResolvedValue({ + stdout: JSON.stringify({ id: 42 }), + stderr: '', + }); + return { + service: new GitHubWebhookService( + mockFeatureRepo, + mockGitPrService, + mockNotificationService, + exec as ExecFunction + ), + exec, + }; +} + +describe('GitHubWebhookService', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('registerWebhookForSingleRepo', () => { + it('registers a webhook and adds it to the registered list', async () => { + const { service, exec } = createService(); + await service.registerWebhookForSingleRepo( + '/home/user/repo', + 'https://tunnel.example.com/api/webhooks/github' + ); + expect(exec).toHaveBeenCalled(); + expect(service.getRegisteredWebhooks()).toHaveLength(1); + expect(service.getRegisteredWebhooks()[0].repoFullName).toBe('owner/repo'); + }); + + it('no-ops when a webhook is already registered for the repo path', async () => { + const { service, exec } = createService(); + await service.registerWebhookForSingleRepo( + '/home/user/repo', + 'https://tunnel.example.com/api/webhooks/github' + ); + await service.registerWebhookForSingleRepo( + '/home/user/repo', + 'https://tunnel.example.com/api/webhooks/github' + ); + // exec is called for stale cleanup (list hooks) + create — only once for registration + expect(service.getRegisteredWebhooks()).toHaveLength(1); + }); + + it('normalizes backslash paths before comparing', async () => { + const { service } = createService(); + await service.registerWebhookForSingleRepo( + '/home/user/repo', + 'https://tunnel.example.com/api/webhooks/github' + ); + // Same path with backslashes (Windows) + await service.registerWebhookForSingleRepo( + '\\home\\user\\repo', + 'https://tunnel.example.com/api/webhooks/github' + ); + expect(service.getRegisteredWebhooks()).toHaveLength(1); + }); + }); + + describe('removeWebhookForRepo', () => { + it('removes a webhook from GitHub and the registered list', async () => { + const { service, exec } = createService(); + await service.registerWebhookForSingleRepo( + '/home/user/repo', + 'https://tunnel.example.com/api/webhooks/github' + ); + expect(service.getRegisteredWebhooks()).toHaveLength(1); + + await service.removeWebhookForRepo('/home/user/repo'); + expect(service.getRegisteredWebhooks()).toHaveLength(0); + // Verify DELETE was called + const deleteCalls = (exec as any).mock.calls.filter((c: string[][]) => + c[1]?.includes('DELETE') + ); + expect(deleteCalls.length).toBeGreaterThan(0); + }); + + it('no-ops when repo path is not found', async () => { + const { service, exec } = createService(); + await service.removeWebhookForRepo('/nonexistent/path'); + // No DELETE calls + const deleteCalls = (exec as any).mock.calls.filter((c: string[][]) => + c[1]?.includes('DELETE') + ); + expect(deleteCalls).toHaveLength(0); + }); + + it('normalizes paths when finding webhook to remove', async () => { + const { service } = createService(); + await service.registerWebhookForSingleRepo( + '/home/user/repo', + 'https://tunnel.example.com/api/webhooks/github' + ); + await service.removeWebhookForRepo('\\home\\user\\repo'); + expect(service.getRegisteredWebhooks()).toHaveLength(0); + }); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `pnpm vitest run packages/core/src/infrastructure/services/webhook/github-webhook.service.test.ts` +Expected: FAIL — `registerWebhookForSingleRepo` and `removeWebhookForRepo` do not exist. + +- [ ] **Step 3: Implement `registerWebhookForSingleRepo` and `removeWebhookForRepo`** + +In `github-webhook.service.ts`, add a helper function at the top of the file: + +```typescript +function normalizePath(p: string): string { + return p.replace(/\\/g, '/'); +} +``` + +Add two new public methods to `GitHubWebhookService`: + +```typescript +/** + * Register a webhook for a single repository. + * No-ops if the repo already has a registered webhook (normalized path comparison). + */ +async registerWebhookForSingleRepo(repoPath: string, webhookUrl: string): Promise { + const normalized = normalizePath(repoPath); + const existing = this.registeredWebhooks.find( + (w) => normalizePath(w.repositoryPath) === normalized + ); + if (existing) return existing; + + await this.registerWebhookForRepo(repoPath, webhookUrl); + + // Return the newly registered webhook (last entry if registration succeeded) + const added = this.registeredWebhooks.find( + (w) => normalizePath(w.repositoryPath) === normalized + ); + return added ?? null; +} + +/** + * Remove the webhook for a single repository. + * No-ops if the repo has no registered webhook. + */ +async removeWebhookForRepo(repoPath: string): Promise { + const normalized = normalizePath(repoPath); + const index = this.registeredWebhooks.findIndex( + (w) => normalizePath(w.repositoryPath) === normalized + ); + if (index === -1) return; + + const webhook = this.registeredWebhooks[index]; + + try { + await this.execFn( + 'gh', + [ + 'api', + '--method', + 'DELETE', + `-H`, + 'Accept: application/vnd.github+json', + `/repos/${webhook.repoFullName}/hooks/${webhook.webhookId}`, + ], + { cwd: webhook.repositoryPath } + ); + // eslint-disable-next-line no-console + console.log(`${TAG} Removed webhook #${webhook.webhookId} for ${webhook.repoFullName}`); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + // eslint-disable-next-line no-console + console.warn(`${TAG} Failed to remove webhook for ${webhook.repoFullName}: ${msg}`); + } + + this.registeredWebhooks.splice(index, 1); +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `pnpm vitest run packages/core/src/infrastructure/services/webhook/github-webhook.service.test.ts` +Expected: All 6 tests PASS. + +- [ ] **Step 5: Commit** + +```bash +git add packages/core/src/infrastructure/services/webhook/github-webhook.service.ts packages/core/src/infrastructure/services/webhook/github-webhook.service.test.ts +git commit -m "feat(web): add single-repo webhook register and remove methods" +``` + +--- + +### Task 2: Add `enableWebhookForRepo`, `disableWebhookForRepo`, `isWebhookEnabledForRepo` to WebhookManagerService + +**Files:** + +- Modify: `packages/core/src/infrastructure/services/webhook/webhook-manager.service.ts` +- Create: `packages/core/src/infrastructure/services/webhook/webhook-manager.service.test.ts` + +**Context:** The manager orchestrates tunnel + webhook service. New methods delegate to `GitHubWebhookService` concrete methods (cast from `IWebhookService`, same pattern as `getStatus()`). `enableWebhookForRepo` must validate the tunnel is running and construct the webhook URL. + +- [ ] **Step 1: Write failing tests** + +```typescript +// packages/core/src/infrastructure/services/webhook/webhook-manager.service.test.ts +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { WebhookManagerService } from './webhook-manager.service.js'; + +function createMocks({ tunnelRunning = true, tunnelUrl = 'https://tunnel.example.com' } = {}) { + const tunnelService = { + start: vi.fn().mockResolvedValue(tunnelUrl), + stop: vi.fn().mockResolvedValue(undefined), + getPublicUrl: vi.fn().mockReturnValue(tunnelRunning ? tunnelUrl : null), + onUrlChange: vi.fn(), + isRunning: vi.fn().mockReturnValue(tunnelRunning), + }; + + const webhookService = { + registerWebhooks: vi.fn().mockResolvedValue(undefined), + updateWebhookUrl: vi.fn().mockResolvedValue(undefined), + removeWebhooks: vi.fn().mockResolvedValue(undefined), + validateSignature: vi.fn().mockReturnValue({ valid: true }), + handleEvent: vi.fn().mockResolvedValue(undefined), + // Concrete GitHubWebhookService methods + registerWebhookForSingleRepo: vi + .fn() + .mockResolvedValue({ repoFullName: 'owner/repo', webhookId: 42, repositoryPath: '/repo' }), + removeWebhookForRepo: vi.fn().mockResolvedValue(undefined), + getRegisteredWebhooks: vi.fn().mockReturnValue([]), + getDeliveryHistory: vi.fn().mockReturnValue([]), + }; + + return { tunnelService, webhookService }; +} + +describe('WebhookManagerService', () => { + describe('enableWebhookForRepo', () => { + it('returns error when tunnel is not running', async () => { + const { tunnelService, webhookService } = createMocks({ tunnelRunning: false }); + const manager = new WebhookManagerService(tunnelService, webhookService); + + const result = await manager.enableWebhookForRepo('/repo'); + expect(result).toEqual({ success: false, error: 'tunnel_not_connected' }); + expect(webhookService.registerWebhookForSingleRepo).not.toHaveBeenCalled(); + }); + + it('registers webhook and returns success when tunnel is running', async () => { + const { tunnelService, webhookService } = createMocks(); + const manager = new WebhookManagerService(tunnelService, webhookService); + + const result = await manager.enableWebhookForRepo('/repo'); + expect(result.success).toBe(true); + expect(result.webhook).toBeDefined(); + expect(webhookService.registerWebhookForSingleRepo).toHaveBeenCalledWith( + '/repo', + 'https://tunnel.example.com/api/webhooks/github' + ); + }); + }); + + describe('disableWebhookForRepo', () => { + it('delegates to webhookService.removeWebhookForRepo', async () => { + const { tunnelService, webhookService } = createMocks(); + const manager = new WebhookManagerService(tunnelService, webhookService); + + const result = await manager.disableWebhookForRepo('/repo'); + expect(result).toEqual({ success: true }); + expect(webhookService.removeWebhookForRepo).toHaveBeenCalledWith('/repo'); + }); + }); + + describe('isWebhookEnabledForRepo', () => { + it('returns false when repo has no webhook', () => { + const { tunnelService, webhookService } = createMocks(); + const manager = new WebhookManagerService(tunnelService, webhookService); + + expect(manager.isWebhookEnabledForRepo('/repo')).toBe(false); + }); + + it('returns true when repo has a webhook (normalized path)', () => { + const { tunnelService, webhookService } = createMocks(); + webhookService.getRegisteredWebhooks.mockReturnValue([ + { repoFullName: 'owner/repo', webhookId: 42, repositoryPath: '/home/user/repo' }, + ]); + const manager = new WebhookManagerService(tunnelService, webhookService); + + expect(manager.isWebhookEnabledForRepo('/home/user/repo')).toBe(true); + expect(manager.isWebhookEnabledForRepo('\\home\\user\\repo')).toBe(true); + }); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `pnpm vitest run packages/core/src/infrastructure/services/webhook/webhook-manager.service.test.ts` +Expected: FAIL — methods do not exist. + +- [ ] **Step 3: Implement the three methods** + +In `webhook-manager.service.ts`, add a `normalizePath` helper at the top: + +```typescript +function normalizePath(p: string): string { + return p.replace(/\\/g, '/'); +} +``` + +Add an interface for the enable result (above the class): + +```typescript +export interface WebhookRepoResult { + success: boolean; + webhook?: RegisteredWebhook; + error?: string; +} +``` + +Add three public methods to `WebhookManagerService`: + +```typescript +async enableWebhookForRepo(repoPath: string): Promise { + if (!this.tunnelService.isRunning() || !this.tunnelService.getPublicUrl()) { + return { success: false, error: 'tunnel_not_connected' }; + } + + const webhookUrl = `${this.tunnelService.getPublicUrl()}/api/webhooks/github`; + const ghService = this.webhookService as GitHubWebhookService; + + try { + const webhook = await ghService.registerWebhookForSingleRepo(repoPath, webhookUrl); + return { success: true, webhook: webhook ?? undefined }; + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + return { success: false, error: msg }; + } +} + +async disableWebhookForRepo(repoPath: string): Promise<{ success: boolean; error?: string }> { + const ghService = this.webhookService as GitHubWebhookService; + + try { + await ghService.removeWebhookForRepo(repoPath); + return { success: true }; + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + return { success: false, error: msg }; + } +} + +isWebhookEnabledForRepo(repoPath: string): boolean { + const ghService = this.webhookService as GitHubWebhookService; + const registered = typeof ghService.getRegisteredWebhooks === 'function' + ? ghService.getRegisteredWebhooks() + : []; + const normalized = normalizePath(repoPath); + return registered.some((w) => normalizePath(w.repositoryPath) === normalized); +} +``` + +Also export `WebhookRepoResult` from the module. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `pnpm vitest run packages/core/src/infrastructure/services/webhook/webhook-manager.service.test.ts` +Expected: All 5 tests PASS. + +- [ ] **Step 5: Commit** + +```bash +git add packages/core/src/infrastructure/services/webhook/webhook-manager.service.ts packages/core/src/infrastructure/services/webhook/webhook-manager.service.test.ts +git commit -m "feat(web): add per-repo enable disable and status methods to webhook manager" +``` + +--- + +## Chunk 2: API Routes + +### Task 3: Create `/api/webhooks/repos/status` endpoint + +**Files:** + +- Create: `src/presentation/web/app/api/webhooks/repos/status/route.ts` + +**Context:** Follow the pattern from `src/presentation/web/app/api/webhooks/status/route.ts`. The endpoint takes `repositoryPath` as a query param and checks the webhook manager's `isWebhookEnabledForRepo()`. + +- [ ] **Step 1: Create the route handler** + +```typescript +// src/presentation/web/app/api/webhooks/repos/status/route.ts +/** + * Per-Repo Webhook Status: GET /api/webhooks/repos/status?repositoryPath=... + * + * Returns whether a webhook is enabled for a specific repository. + */ + +import { + hasWebhookManager, + getWebhookManager, +} from '@shepai/core/infrastructure/services/webhook/webhook-manager.service'; +import type { GitHubWebhookService } from '@shepai/core/infrastructure/services/webhook/github-webhook.service'; + +export const dynamic = 'force-dynamic'; + +export async function GET(request: Request): Promise { + const { searchParams } = new URL(request.url); + const repositoryPath = searchParams.get('repositoryPath'); + + if (!repositoryPath) { + return Response.json({ error: 'repositoryPath query parameter is required' }, { status: 400 }); + } + + if (!hasWebhookManager()) { + return Response.json({ enabled: false }); + } + + const manager = getWebhookManager(); + const enabled = manager.isWebhookEnabledForRepo(repositoryPath); + + if (!enabled) { + return Response.json({ enabled: false }); + } + + // Find the specific webhook details + const ghService = (manager as any).webhookService as GitHubWebhookService; + const normalized = repositoryPath.replace(/\\/g, '/'); + const webhook = ghService + .getRegisteredWebhooks() + .find((w) => w.repositoryPath.replace(/\\/g, '/') === normalized); + + return Response.json({ + enabled: true, + webhookId: webhook?.webhookId, + repoFullName: webhook?.repoFullName, + }); +} +``` + +- [ ] **Step 2: Verify the route compiles** + +Run: `pnpm tsc --noEmit --project src/presentation/web/tsconfig.json 2>&1 | head -20` +Expected: No type errors for this file. + +- [ ] **Step 3: Commit** + +```bash +git add src/presentation/web/app/api/webhooks/repos/status/route.ts +git commit -m "feat(web): add per-repo webhook status api endpoint" +``` + +--- + +### Task 4: Create `/api/webhooks/repos/enable` endpoint + +**Files:** + +- Create: `src/presentation/web/app/api/webhooks/repos/enable/route.ts` + +- [ ] **Step 1: Create the route handler** + +```typescript +// src/presentation/web/app/api/webhooks/repos/enable/route.ts +/** + * Enable Webhook for Repo: POST /api/webhooks/repos/enable + * + * Body: { repositoryPath: string } + * Registers a GitHub webhook for the given repository. + */ + +import { + hasWebhookManager, + getWebhookManager, +} from '@shepai/core/infrastructure/services/webhook/webhook-manager.service'; + +export const dynamic = 'force-dynamic'; + +export async function POST(request: Request): Promise { + let body: { repositoryPath?: string }; + try { + body = await request.json(); + } catch { + return Response.json({ success: false, error: 'Invalid JSON body' }, { status: 400 }); + } + + const { repositoryPath } = body; + if (!repositoryPath) { + return Response.json({ success: false, error: 'repositoryPath is required' }, { status: 400 }); + } + + if (!hasWebhookManager()) { + return Response.json( + { success: false, error: 'Webhook system not initialized' }, + { status: 503 } + ); + } + + const manager = getWebhookManager(); + const result = await manager.enableWebhookForRepo(repositoryPath); + return Response.json(result, { status: result.success ? 200 : 422 }); +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add src/presentation/web/app/api/webhooks/repos/enable/route.ts +git commit -m "feat(web): add enable webhook api endpoint" +``` + +--- + +### Task 5: Create `/api/webhooks/repos/disable` endpoint + +**Files:** + +- Create: `src/presentation/web/app/api/webhooks/repos/disable/route.ts` + +- [ ] **Step 1: Create the route handler** + +```typescript +// src/presentation/web/app/api/webhooks/repos/disable/route.ts +/** + * Disable Webhook for Repo: POST /api/webhooks/repos/disable + * + * Body: { repositoryPath: string } + * Removes the GitHub webhook for the given repository. + */ + +import { + hasWebhookManager, + getWebhookManager, +} from '@shepai/core/infrastructure/services/webhook/webhook-manager.service'; + +export const dynamic = 'force-dynamic'; + +export async function POST(request: Request): Promise { + let body: { repositoryPath?: string }; + try { + body = await request.json(); + } catch { + return Response.json({ success: false, error: 'Invalid JSON body' }, { status: 400 }); + } + + const { repositoryPath } = body; + if (!repositoryPath) { + return Response.json({ success: false, error: 'repositoryPath is required' }, { status: 400 }); + } + + if (!hasWebhookManager()) { + return Response.json( + { success: false, error: 'Webhook system not initialized' }, + { status: 503 } + ); + } + + const manager = getWebhookManager(); + const result = await manager.disableWebhookForRepo(repositoryPath); + return Response.json(result); +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add src/presentation/web/app/api/webhooks/repos/disable/route.ts +git commit -m "feat(web): add disable webhook api endpoint" +``` + +--- + +## Chunk 3: UI Hook + +### Task 6: Create `useWebhookAction` hook + +**Files:** + +- Create: `src/presentation/web/hooks/use-webhook-action.ts` + +**Context:** Follow the `useRepositoryActions` pattern (error auto-clear after 5s) and `useDeployAction` pattern (mounted ref, cleanup). Fetches both tunnel status and per-repo webhook status on mount. Provides optimistic toggle with rollback. + +- [ ] **Step 1: Create the hook** + +```typescript +// src/presentation/web/hooks/use-webhook-action.ts +'use client'; + +import { useState, useCallback, useRef, useEffect } from 'react'; + +export interface WebhookActionState { + toggle: () => Promise; + enabled: boolean; + loading: boolean; + error: string | null; + tunnelConnected: boolean; + webhookId: number | undefined; + repoFullName: string | undefined; + initializing: boolean; +} + +const ERROR_CLEAR_DELAY = 5000; + +export function useWebhookAction(repositoryPath: string | null): WebhookActionState { + const [enabled, setEnabled] = useState(false); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [tunnelConnected, setTunnelConnected] = useState(false); + const [webhookId, setWebhookId] = useState(); + const [repoFullName, setRepoFullName] = useState(); + const [initializing, setInitializing] = useState(true); + + const errorTimerRef = useRef | null>(null); + const mountedRef = useRef(true); + + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + }; + }, []); + + useEffect(() => { + return () => { + if (errorTimerRef.current) clearTimeout(errorTimerRef.current); + }; + }, []); + + // Fetch initial status on mount + useEffect(() => { + if (!repositoryPath) { + setInitializing(false); + return; + } + + let cancelled = false; + + async function fetchStatus() { + try { + const [tunnelRes, repoRes] = await Promise.all([ + fetch('/api/webhooks/status'), + fetch(`/api/webhooks/repos/status?repositoryPath=${encodeURIComponent(repositoryPath!)}`), + ]); + + if (cancelled || !mountedRef.current) return; + + const tunnelData = await tunnelRes.json(); + const repoData = await repoRes.json(); + + if (cancelled || !mountedRef.current) return; + + setTunnelConnected(tunnelData.tunnel?.connected ?? false); + setEnabled(repoData.enabled ?? false); + setWebhookId(repoData.webhookId); + setRepoFullName(repoData.repoFullName); + } catch { + // Silently fail — UI will show default disabled state + } finally { + if (!cancelled && mountedRef.current) { + setInitializing(false); + } + } + } + + void fetchStatus(); + return () => { + cancelled = true; + }; + }, [repositoryPath]); + + const handleToggle = useCallback(async () => { + if (!repositoryPath || loading) return; + + if (errorTimerRef.current) clearTimeout(errorTimerRef.current); + + const wasEnabled = enabled; + const endpoint = wasEnabled ? '/api/webhooks/repos/disable' : '/api/webhooks/repos/enable'; + + // Optimistic update + setEnabled(!wasEnabled); + setLoading(true); + setError(null); + + try { + const res = await fetch(endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ repositoryPath }), + }); + + if (!mountedRef.current) return; + + const data = await res.json(); + + if (!data.success) { + // Rollback + setEnabled(wasEnabled); + const errorMsg = data.error ?? 'An unexpected error occurred'; + setError(errorMsg); + errorTimerRef.current = setTimeout(() => { + if (mountedRef.current) setError(null); + }, ERROR_CLEAR_DELAY); + } else { + // Update details from server response + if (!wasEnabled && data.webhook) { + setWebhookId(data.webhook.webhookId); + setRepoFullName(data.webhook.repoFullName); + } else if (wasEnabled) { + setWebhookId(undefined); + setRepoFullName(undefined); + } + } + } catch (err: unknown) { + if (!mountedRef.current) return; + // Rollback + setEnabled(wasEnabled); + const errorMsg = err instanceof Error ? err.message : 'An unexpected error occurred'; + setError(errorMsg); + errorTimerRef.current = setTimeout(() => { + if (mountedRef.current) setError(null); + }, ERROR_CLEAR_DELAY); + } finally { + if (mountedRef.current) { + setLoading(false); + } + } + }, [repositoryPath, loading, enabled]); + + return { + toggle: handleToggle, + enabled, + loading, + error, + tunnelConnected, + webhookId, + repoFullName, + initializing, + }; +} +``` + +- [ ] **Step 2: Verify no type errors** + +Run: `pnpm tsc --noEmit --project src/presentation/web/tsconfig.json 2>&1 | head -20` +Expected: No type errors. + +- [ ] **Step 3: Commit** + +```bash +git add src/presentation/web/hooks/use-webhook-action.ts +git commit -m "feat(web): add use-webhook-action hook with optimistic toggle" +``` + +--- + +## Chunk 4: UI Components + +### Task 7: Add webhook action button to RepositoryNode + +**Files:** + +- Modify: `src/presentation/web/components/common/repository-node/repository-node.tsx` + +**Context:** Add a `Radio` icon button in the action row between `FolderOpen` and `FeatureSessionsDropdown`. Use the `useWebhookAction` hook. When `tunnelConnected` is false, the button is disabled. When `enabled` is true, the icon is green. Use existing `ActionButton` + `TooltipProvider` pattern from the adjacent buttons. + +- [ ] **Step 1: Add the webhook button to the node** + +In `repository-node.tsx`: + +1. Add imports: + +```typescript +import { Radio } from 'lucide-react'; +import { useWebhookAction } from '@/hooks/use-webhook-action'; +``` + +2. Inside the `RepositoryNode` function, add the hook call after `useRepositoryActions`: + +```typescript +const webhookAction = useWebhookAction(data.repositoryPath ?? null); +``` + +3. After the `FolderOpen` `TooltipProvider` block (line ~233) and before `FeatureSessionsDropdown` (line ~234), insert: + +```tsx + + + + + + + + + {!webhookAction.tunnelConnected + ? 'Webhook unavailable — tunnel not running' + : webhookAction.enabled + ? 'Disable webhook' + : 'Enable webhook'} + + + +``` + +**Note:** The `ActionButton` component does not currently accept `disabled` or `className` props. Check if these need to be added. If `ActionButton` doesn't support `disabled`, pass it through by adding `disabled?: boolean` and `className?: string` to `ActionButtonProps` and forwarding them to the ` + {expanded ? ( +
+
+
+ Status:{' '} + + {STATUS_LABEL[delivery.status]} + +
+
+ Date: {dateStr} {timeStr} +
+
+ Duration: {delivery.durationMs}ms +
+
+ Source: {delivery.source} +
+
+
+ Message: +

{delivery.statusMessage}

+
+
+ Payload: +
+              {JSON.stringify(delivery.payload, null, 2)}
+            
+
+
+ ) : null} + + ); +} diff --git a/src/presentation/web/components/features/webhooks/webhook-repo-list.stories.tsx b/src/presentation/web/components/features/webhooks/webhook-repo-list.stories.tsx new file mode 100644 index 000000000..76ff39dbb --- /dev/null +++ b/src/presentation/web/components/features/webhooks/webhook-repo-list.stories.tsx @@ -0,0 +1,39 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { WebhookRepoList } from './webhook-repo-list'; + +const mockWebhooks = [ + { repoFullName: 'acme/web-app', webhookId: 12345, repositoryPath: '/home/user/web-app' }, + { repoFullName: 'acme/api-service', webhookId: 12346, repositoryPath: '/home/user/api-service' }, + { repoFullName: 'acme/shared-lib', webhookId: 12347, repositoryPath: '/home/user/shared-lib' }, +]; + +const meta: Meta = { + title: 'Features/Webhooks/WebhookRepoList', + component: WebhookRepoList, + parameters: { layout: 'padded' }, + tags: ['autodocs'], +}; + +export default meta; +type Story = StoryObj; + +export const WithWebhooks: Story = { + args: { + webhooks: mockWebhooks, + tunnelUrl: 'https://random-subdomain.trycloudflare.com', + }, +}; + +export const Empty: Story = { + args: { + webhooks: [], + tunnelUrl: null, + }, +}; + +export const SingleRepo: Story = { + args: { + webhooks: [mockWebhooks[0]], + tunnelUrl: 'https://random-subdomain.trycloudflare.com', + }, +}; diff --git a/src/presentation/web/components/features/webhooks/webhook-repo-list.tsx b/src/presentation/web/components/features/webhooks/webhook-repo-list.tsx new file mode 100644 index 000000000..c084472a2 --- /dev/null +++ b/src/presentation/web/components/features/webhooks/webhook-repo-list.tsx @@ -0,0 +1,72 @@ +'use client'; + +import { GitBranch, ExternalLink } from 'lucide-react'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import type { RegisteredWebhookInfo } from './types'; + +export interface WebhookRepoListProps { + webhooks: readonly RegisteredWebhookInfo[]; + tunnelUrl: string | null; +} + +export function WebhookRepoList({ webhooks, tunnelUrl }: WebhookRepoListProps) { + if (webhooks.length === 0) { + return ( + + + Registered Repositories + + +

+ No webhooks registered. Webhooks are automatically created for repositories with + features in the Review lifecycle when cloudflared is available. +

+
+
+ ); + } + + return ( + + + + Registered Repositories ({webhooks.length}) + + + +
+ {webhooks.map((webhook) => ( +
+
+ +
+

{webhook.repoFullName}

+

Webhook #{webhook.webhookId}

+
+
+
+ + Active + + {tunnelUrl ? ( + + + + ) : null} +
+
+ ))} +
+
+
+ ); +} diff --git a/src/presentation/web/components/features/webhooks/webhook-status-cards.stories.tsx b/src/presentation/web/components/features/webhooks/webhook-status-cards.stories.tsx new file mode 100644 index 000000000..05e78915e --- /dev/null +++ b/src/presentation/web/components/features/webhooks/webhook-status-cards.stories.tsx @@ -0,0 +1,66 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { WebhookStatusCards } from './webhook-status-cards'; +import type { WebhookSystemStatus } from './types'; + +const activeStatus: WebhookSystemStatus = { + running: true, + tunnel: { + connected: true, + publicUrl: 'https://random-subdomain.trycloudflare.com', + }, + webhooks: { + registered: [ + { repoFullName: 'acme/web-app', webhookId: 12345, repositoryPath: '/home/user/web-app' }, + { + repoFullName: 'acme/api-service', + webhookId: 12346, + repositoryPath: '/home/user/api-service', + }, + ], + totalDeliveries: 42, + successCount: 38, + errorCount: 2, + ignoredCount: 2, + }, + startedAt: new Date(Date.now() - 3600000).toISOString(), +}; + +const inactiveStatus: WebhookSystemStatus = { + running: false, + tunnel: { connected: false, publicUrl: null }, + webhooks: { + registered: [], + totalDeliveries: 0, + successCount: 0, + errorCount: 0, + ignoredCount: 0, + }, + startedAt: null, +}; + +const meta: Meta = { + title: 'Features/Webhooks/WebhookStatusCards', + component: WebhookStatusCards, + parameters: { layout: 'padded' }, + tags: ['autodocs'], +}; + +export default meta; +type Story = StoryObj; + +export const Active: Story = { + args: { status: activeStatus }, +}; + +export const Inactive: Story = { + args: { status: inactiveStatus }, +}; + +export const TunnelDisconnected: Story = { + args: { + status: { + ...activeStatus, + tunnel: { connected: false, publicUrl: null }, + }, + }, +}; diff --git a/src/presentation/web/components/features/webhooks/webhook-status-cards.tsx b/src/presentation/web/components/features/webhooks/webhook-status-cards.tsx new file mode 100644 index 000000000..dcc848877 --- /dev/null +++ b/src/presentation/web/components/features/webhooks/webhook-status-cards.tsx @@ -0,0 +1,115 @@ +'use client'; + +import { Activity, Globe, Webhook, CheckCircle, XCircle, MinusCircle } from 'lucide-react'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { cn } from '@/lib/utils'; +import type { WebhookSystemStatus } from './types'; + +export interface WebhookStatusCardsProps { + status: WebhookSystemStatus; +} + +export function WebhookStatusCards({ status }: WebhookStatusCardsProps) { + const uptime = status.startedAt ? formatUptime(new Date(status.startedAt)) : null; + + return ( +
+ {/* System Status */} + + + System Status + + + +
+
+ {status.running ? 'Active' : 'Inactive'} +
+ {uptime ? ( +

Up for {uptime}

+ ) : ( +

Webhook system not started

+ )} + + + + {/* Tunnel Status */} + + + Tunnel + + + +
+ + {status.tunnel.connected ? 'Connected' : 'Disconnected'} + +
+ {status.tunnel.publicUrl ? ( +

+ {status.tunnel.publicUrl} +

+ ) : ( +

No tunnel URL

+ )} +
+
+ + {/* Registered Webhooks */} + + + Webhooks + + + +
{status.webhooks.registered.length}
+

+ {status.webhooks.registered.length === 1 ? 'repository' : 'repositories'} registered +

+
+
+ + {/* Delivery Stats */} + + + Deliveries + + + +
{status.webhooks.totalDeliveries}
+
+ + + {status.webhooks.successCount} + + + + {status.webhooks.errorCount} + + + + {status.webhooks.ignoredCount} + +
+
+
+
+ ); +} + +function formatUptime(startedAt: Date): string { + const ms = Date.now() - startedAt.getTime(); + const seconds = Math.floor(ms / 1000); + const minutes = Math.floor(seconds / 60); + const hours = Math.floor(minutes / 60); + + if (hours > 0) return `${hours}h ${minutes % 60}m`; + if (minutes > 0) return `${minutes}m ${seconds % 60}s`; + return `${seconds}s`; +} diff --git a/src/presentation/web/components/features/webhooks/webhooks-page-client.stories.tsx b/src/presentation/web/components/features/webhooks/webhooks-page-client.stories.tsx new file mode 100644 index 000000000..8dbb1df9b --- /dev/null +++ b/src/presentation/web/components/features/webhooks/webhooks-page-client.stories.tsx @@ -0,0 +1,68 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { WebhooksPageClient } from './webhooks-page-client'; +import type { WebhookSystemStatus } from './types'; + +const activeStatus: WebhookSystemStatus = { + running: true, + tunnel: { + connected: true, + publicUrl: 'https://random-subdomain.trycloudflare.com', + }, + webhooks: { + registered: [ + { repoFullName: 'acme/web-app', webhookId: 12345, repositoryPath: '/home/user/web-app' }, + { + repoFullName: 'acme/api-service', + webhookId: 12346, + repositoryPath: '/home/user/api-service', + }, + ], + totalDeliveries: 15, + successCount: 12, + errorCount: 1, + ignoredCount: 2, + }, + startedAt: new Date(Date.now() - 7200000).toISOString(), +}; + +const inactiveStatus: WebhookSystemStatus = { + running: false, + tunnel: { connected: false, publicUrl: null }, + webhooks: { + registered: [], + totalDeliveries: 0, + successCount: 0, + errorCount: 0, + ignoredCount: 0, + }, + startedAt: null, +}; + +const meta: Meta = { + title: 'Features/Webhooks/WebhooksPageClient', + component: WebhooksPageClient, + parameters: { + layout: 'padded', + mockData: [ + { url: '/api/webhooks/status', method: 'GET', status: 200, response: activeStatus }, + { + url: '/api/webhooks/deliveries?limit=100', + method: 'GET', + status: 200, + response: { deliveries: [] }, + }, + ], + }, + tags: ['autodocs'], +}; + +export default meta; +type Story = StoryObj; + +export const Active: Story = { + args: { initialStatus: activeStatus }, +}; + +export const Inactive: Story = { + args: { initialStatus: inactiveStatus }, +}; diff --git a/src/presentation/web/components/features/webhooks/webhooks-page-client.tsx b/src/presentation/web/components/features/webhooks/webhooks-page-client.tsx new file mode 100644 index 000000000..d29fabc8a --- /dev/null +++ b/src/presentation/web/components/features/webhooks/webhooks-page-client.tsx @@ -0,0 +1,73 @@ +'use client'; + +import { useState, useEffect, useCallback } from 'react'; +import { RefreshCw } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { PageHeader } from '@/components/common/page-header'; +import { WebhookStatusCards } from './webhook-status-cards'; +import { WebhookRepoList } from './webhook-repo-list'; +import { WebhookDeliveryTable } from './webhook-delivery-table'; +import type { WebhookSystemStatus, WebhookDeliveryRecord } from './types'; + +export interface WebhooksPageClientProps { + initialStatus: WebhookSystemStatus; +} + +export function WebhooksPageClient({ initialStatus }: WebhooksPageClientProps) { + const [status, setStatus] = useState(initialStatus); + const [deliveries, setDeliveries] = useState([]); + const [refreshing, setRefreshing] = useState(false); + + const refresh = useCallback(async () => { + setRefreshing(true); + try { + const [statusRes, deliveriesRes] = await Promise.all([ + fetch('/api/webhooks/status'), + fetch('/api/webhooks/deliveries?limit=100'), + ]); + if (statusRes.ok) { + setStatus(await statusRes.json()); + } + if (deliveriesRes.ok) { + const data = await deliveriesRes.json(); + setDeliveries(data.deliveries); + } + } finally { + setRefreshing(false); + } + }, []); + + useEffect(() => { + void refresh(); + const interval = setInterval(() => void refresh(), 5000); + return () => clearInterval(interval); + }, [refresh]); + + return ( +
+ + + + + + +
+
+ +
+
+ +
+
+
+ ); +} diff --git a/src/presentation/web/components/layouts/app-sidebar/app-sidebar.tsx b/src/presentation/web/components/layouts/app-sidebar/app-sidebar.tsx index 705281304..3008fca9d 100644 --- a/src/presentation/web/components/layouts/app-sidebar/app-sidebar.tsx +++ b/src/presentation/web/components/layouts/app-sidebar/app-sidebar.tsx @@ -1,7 +1,7 @@ 'use client'; import { usePathname } from 'next/navigation'; -import { Home, Wrench, Puzzle, Settings } from 'lucide-react'; +import { Home, Wrench, Puzzle, Settings, Webhook } from 'lucide-react'; import { Sidebar, SidebarHeader, @@ -112,6 +112,12 @@ export function AppSidebar({ active={pathname === '/skills'} /> ) : null} + ('ITunnelService'); + const webhookService = container.resolve('IWebhookService'); + initializeWebhookManager(tunnelService, webhookService); + // Start is async and non-blocking — failures are logged, not thrown + void getWebhookManager().start(port); + } catch (error) { + console.warn('[dev-server] Webhook system init failed (using polling fallback):', error); + } } catch (error) { console.warn('[dev-server] DI initialization failed — features will be empty:', error); } @@ -132,6 +150,13 @@ async function main() { console.log('\n[dev-server] Shutting down...'); const forceExit = setTimeout(() => process.exit(0), 2000); try { + try { + if (hasWebhookManager()) { + await getWebhookManager().stop(); + } + } catch { + /* not initialized */ + } try { getNotificationWatcher().stop(); } catch { diff --git a/src/presentation/web/hooks/use-webhook-action.ts b/src/presentation/web/hooks/use-webhook-action.ts new file mode 100644 index 000000000..c07924b9a --- /dev/null +++ b/src/presentation/web/hooks/use-webhook-action.ts @@ -0,0 +1,153 @@ +'use client'; + +import { useState, useCallback, useRef, useEffect } from 'react'; + +export interface WebhookActionState { + toggle: () => Promise; + enabled: boolean; + loading: boolean; + error: string | null; + tunnelConnected: boolean; + webhookId: number | undefined; + repoFullName: string | undefined; + initializing: boolean; +} + +const ERROR_CLEAR_DELAY = 5000; + +export function useWebhookAction(repositoryPath: string | null): WebhookActionState { + const [enabled, setEnabled] = useState(false); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [tunnelConnected, setTunnelConnected] = useState(false); + const [webhookId, setWebhookId] = useState(); + const [repoFullName, setRepoFullName] = useState(); + const [initializing, setInitializing] = useState(true); + + const errorTimerRef = useRef | null>(null); + const mountedRef = useRef(true); + + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + }; + }, []); + + useEffect(() => { + return () => { + if (errorTimerRef.current) clearTimeout(errorTimerRef.current); + }; + }, []); + + // Fetch initial status on mount + useEffect(() => { + if (!repositoryPath) { + setInitializing(false); + return; + } + + let cancelled = false; + + async function fetchStatus() { + try { + const [tunnelRes, repoRes] = await Promise.all([ + fetch('/api/webhooks/status'), + fetch(`/api/webhooks/repos/status?repositoryPath=${encodeURIComponent(repositoryPath!)}`), + ]); + + if (cancelled || !mountedRef.current) return; + + const tunnelData = await tunnelRes.json(); + const repoData = await repoRes.json(); + + if (cancelled || !mountedRef.current) return; + + setTunnelConnected(tunnelData.tunnel?.connected ?? false); + setEnabled(repoData.enabled ?? false); + setWebhookId(repoData.webhookId); + setRepoFullName(repoData.repoFullName); + } catch { + // Silently fail — UI will show default disabled state + } finally { + if (!cancelled && mountedRef.current) { + setInitializing(false); + } + } + } + + void fetchStatus(); + return () => { + cancelled = true; + }; + }, [repositoryPath]); + + const handleToggle = useCallback(async () => { + if (!repositoryPath || loading) return; + + if (errorTimerRef.current) clearTimeout(errorTimerRef.current); + + const wasEnabled = enabled; + const endpoint = wasEnabled ? '/api/webhooks/repos/disable' : '/api/webhooks/repos/enable'; + + // Optimistic update + setEnabled(!wasEnabled); + setLoading(true); + setError(null); + + try { + const res = await fetch(endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ repositoryPath }), + }); + + if (!mountedRef.current) return; + + const data = await res.json(); + + if (!data.success) { + // Rollback + setEnabled(wasEnabled); + const errorMsg = data.error ?? 'An unexpected error occurred'; + setError(errorMsg); + errorTimerRef.current = setTimeout(() => { + if (mountedRef.current) setError(null); + }, ERROR_CLEAR_DELAY); + } else { + // Update details from server response + if (!wasEnabled && data.webhook) { + setWebhookId(data.webhook.webhookId); + setRepoFullName(data.webhook.repoFullName); + } else if (wasEnabled) { + setWebhookId(undefined); + setRepoFullName(undefined); + } + } + } catch (err: unknown) { + if (!mountedRef.current) return; + // Rollback + setEnabled(wasEnabled); + const errorMsg = err instanceof Error ? err.message : 'An unexpected error occurred'; + setError(errorMsg); + errorTimerRef.current = setTimeout(() => { + if (mountedRef.current) setError(null); + }, ERROR_CLEAR_DELAY); + } finally { + if (mountedRef.current) { + setLoading(false); + } + } + }, [repositoryPath, loading, enabled]); + + return { + toggle: handleToggle, + enabled, + loading, + error, + tunnelConnected, + webhookId, + repoFullName, + initializing, + }; +} diff --git a/tests/unit/infrastructure/services/tunnel/cloudflare-tunnel.service.test.ts b/tests/unit/infrastructure/services/tunnel/cloudflare-tunnel.service.test.ts new file mode 100644 index 000000000..52f6b377c --- /dev/null +++ b/tests/unit/infrastructure/services/tunnel/cloudflare-tunnel.service.test.ts @@ -0,0 +1,262 @@ +/** + * Cloudflare Tunnel Service Unit Tests + * + * Tests for tunnel lifecycle management, URL detection, + * URL change notifications, and webhook-only proxy filtering. + * + * TDD Phase: GREEN + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { EventEmitter } from 'node:events'; +import http from 'node:http'; +import { CloudflareTunnelService } from '@/infrastructure/services/tunnel/cloudflare-tunnel.service.js'; +import type { TunnelLike } from '@/infrastructure/services/tunnel/cloudflare-tunnel.service.js'; + +function createMockTunnel() { + const emitter = new EventEmitter() as EventEmitter & TunnelLike; + (emitter as EventEmitter & TunnelLike & { stop: () => void }).stop = vi.fn(); + return emitter as EventEmitter & TunnelLike & { stop: ReturnType }; +} + +/** + * Emit an event on the next macrotask tick. + * Required because `start()` uses `await` internally, so event listeners + * aren't attached until after the microtask queue drains. + */ +function emitNext(emitter: EventEmitter, event: string, ...args: unknown[]): void { + setTimeout(() => emitter.emit(event, ...args), 0); +} + +/** + * Helper: make an HTTP request and return status + body. + */ +function httpGet(port: number, path: string): Promise<{ status: number; body: string }> { + return new Promise((resolve, reject) => { + const req = http.get(`http://127.0.0.1:${port}${path}`, (res) => { + let body = ''; + res.on('data', (chunk) => (body += chunk)); + res.on('end', () => resolve({ status: res.statusCode ?? 0, body })); + }); + req.on('error', reject); + }); +} + +describe('CloudflareTunnelService', () => { + let service: CloudflareTunnelService; + let mockCreateTunnel: (origin: string) => TunnelLike; + let mockTunnel: ReturnType; + let capturedOrigin: string | null; + + beforeEach(() => { + capturedOrigin = null; + mockTunnel = createMockTunnel(); + mockCreateTunnel = vi.fn((origin: string) => { + capturedOrigin = origin; + return mockTunnel; + }) as unknown as (origin: string) => TunnelLike; + service = new CloudflareTunnelService({ createTunnel: mockCreateTunnel }); + }); + + afterEach(async () => { + await service.stop(); + }); + + describe('start', () => { + it('should create a tunnel pointing to the proxy port, not the app port', async () => { + emitNext(mockTunnel, 'url', 'https://test-abc123.trycloudflare.com'); + + await service.start(3000); + + // The tunnel should connect to the proxy port, not 3000 + expect(capturedOrigin).toMatch(/^http:\/\/localhost:\d+$/); + expect(capturedOrigin).not.toBe('http://localhost:3000'); + }); + + it('should resolve with tunnel URL from url event', async () => { + emitNext(mockTunnel, 'url', 'https://my-tunnel-url.trycloudflare.com'); + + const url = await service.start(4050); + expect(url).toBe('https://my-tunnel-url.trycloudflare.com'); + }); + + it('should reject if tunnel emits an error', async () => { + emitNext(mockTunnel, 'error', new Error('tunnel failed')); + + await expect(service.start(3000)).rejects.toThrow('Failed to start tunnel'); + }); + + it('should reject if tunnel exits before emitting URL', async () => { + emitNext(mockTunnel, 'exit', 1, null); + + await expect(service.start(3000)).rejects.toThrow('exited with code 1'); + }); + + it('should throw if tunnel is already running', async () => { + emitNext(mockTunnel, 'url', 'https://first.trycloudflare.com'); + await service.start(3000); + + await expect(service.start(3000)).rejects.toThrow('already running'); + }); + + it('should report isRunning correctly', async () => { + expect(service.isRunning()).toBe(false); + + emitNext(mockTunnel, 'url', 'https://test.trycloudflare.com'); + await service.start(3000); + + expect(service.isRunning()).toBe(true); + expect(service.getPublicUrl()).toBe('https://test.trycloudflare.com'); + }); + + it('should reject if createTunnel throws', async () => { + const failingService = new CloudflareTunnelService({ + createTunnel: () => { + throw new Error('binary not found'); + }, + }); + + await expect(failingService.start(3000)).rejects.toThrow('Failed to create tunnel'); + }); + }); + + describe('webhook-only proxy', () => { + let targetServer: http.Server; + let targetPort: number; + + beforeEach(async () => { + // Start a simple target server that echoes paths + targetServer = http.createServer((req, res) => { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ path: req.url, method: req.method })); + }); + + await new Promise((resolve) => { + targetServer.listen(0, '127.0.0.1', () => { + const addr = targetServer.address(); + targetPort = (addr as { port: number }).port; + resolve(); + }); + }); + }); + + afterEach(async () => { + await service.stop(); + await new Promise((resolve) => targetServer.close(() => resolve())); + }); + + it('should forward /api/webhooks/github to the main app', async () => { + emitNext(mockTunnel, 'url', 'https://test.trycloudflare.com'); + await service.start(targetPort); + + // Extract the proxy port from the captured origin + const proxyPort = parseInt(capturedOrigin!.split(':').pop()!, 10); + const result = await httpGet(proxyPort, '/api/webhooks/github'); + + expect(result.status).toBe(200); + const body = JSON.parse(result.body); + expect(body.path).toBe('/api/webhooks/github'); + }); + + it('should forward /api/webhooks/status to the main app', async () => { + emitNext(mockTunnel, 'url', 'https://test.trycloudflare.com'); + await service.start(targetPort); + + const proxyPort = parseInt(capturedOrigin!.split(':').pop()!, 10); + const result = await httpGet(proxyPort, '/api/webhooks/status'); + + expect(result.status).toBe(200); + const body = JSON.parse(result.body); + expect(body.path).toBe('/api/webhooks/status'); + }); + + it('should block non-webhook paths with 404', async () => { + emitNext(mockTunnel, 'url', 'https://test.trycloudflare.com'); + await service.start(targetPort); + + const proxyPort = parseInt(capturedOrigin!.split(':').pop()!, 10); + const result = await httpGet(proxyPort, '/'); + + expect(result.status).toBe(404); + }); + + it('should block /api/agent-events with 404', async () => { + emitNext(mockTunnel, 'url', 'https://test.trycloudflare.com'); + await service.start(targetPort); + + const proxyPort = parseInt(capturedOrigin!.split(':').pop()!, 10); + const result = await httpGet(proxyPort, '/api/agent-events'); + + expect(result.status).toBe(404); + }); + + it('should block /_next paths with 404', async () => { + emitNext(mockTunnel, 'url', 'https://test.trycloudflare.com'); + await service.start(targetPort); + + const proxyPort = parseInt(capturedOrigin!.split(':').pop()!, 10); + const result = await httpGet(proxyPort, '/_next/static/chunks/main.js'); + + expect(result.status).toBe(404); + }); + }); + + describe('URL change detection', () => { + it('should notify handlers when URL changes', async () => { + const handler = vi.fn(); + service.onUrlChange(handler); + + emitNext(mockTunnel, 'url', 'https://first-url.trycloudflare.com'); + await service.start(3000); + + // Simulate reconnection with new URL + mockTunnel.emit('url', 'https://second-url.trycloudflare.com'); + + expect(handler).toHaveBeenCalledWith('https://second-url.trycloudflare.com'); + expect(service.getPublicUrl()).toBe('https://second-url.trycloudflare.com'); + }); + + it('should not notify when same URL is emitted', async () => { + const handler = vi.fn(); + service.onUrlChange(handler); + + emitNext(mockTunnel, 'url', 'https://same-url.trycloudflare.com'); + await service.start(3000); + + // Emit same URL again + mockTunnel.emit('url', 'https://same-url.trycloudflare.com'); + + expect(handler).not.toHaveBeenCalled(); + }); + + it('should handle async URL change handlers gracefully', async () => { + const handler = vi.fn().mockRejectedValue(new Error('handler error')); + service.onUrlChange(handler); + + emitNext(mockTunnel, 'url', 'https://first.trycloudflare.com'); + await service.start(3000); + + // Should not throw even if handler rejects + mockTunnel.emit('url', 'https://second.trycloudflare.com'); + + expect(handler).toHaveBeenCalled(); + }); + }); + + describe('stop', () => { + it('should stop the tunnel, proxy, and clear URL', async () => { + emitNext(mockTunnel, 'url', 'https://test.trycloudflare.com'); + await service.start(3000); + + await service.stop(); + + expect(mockTunnel.stop).toHaveBeenCalled(); + expect(service.isRunning()).toBe(false); + expect(service.getPublicUrl()).toBeNull(); + }); + + it('should be safe to call stop when not running', async () => { + await expect(service.stop()).resolves.toBeUndefined(); + }); + }); +}); diff --git a/tests/unit/infrastructure/services/webhook/github-webhook.service.test.ts b/tests/unit/infrastructure/services/webhook/github-webhook.service.test.ts new file mode 100644 index 000000000..c9f639a8f --- /dev/null +++ b/tests/unit/infrastructure/services/webhook/github-webhook.service.test.ts @@ -0,0 +1,523 @@ +/** + * GitHub Webhook Service Unit Tests + * + * Tests for signature validation, event handling, and webhook registration. + * + * TDD Phase: GREEN + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { createHmac } from 'node:crypto'; +import { GitHubWebhookService } from '@/infrastructure/services/webhook/github-webhook.service.js'; +import { SdlcLifecycle, PrStatus, CiStatus } from '@/domain/generated/output.js'; +import type { IFeatureRepository } from '@/application/ports/output/repositories/feature-repository.interface.js'; +import type { IGitPrService } from '@/application/ports/output/services/git-pr-service.interface.js'; +import type { INotificationService } from '@/application/ports/output/services/notification-service.interface.js'; +import type { Feature } from '@/domain/generated/output.js'; + +function createMockFeature(overrides: Partial = {}): Feature { + return { + id: 'feat-1', + name: 'Test Feature', + slug: 'test-feature', + description: 'A test feature', + lifecycle: SdlcLifecycle.Review, + branch: 'feat/test', + repositoryPath: '/repo/path', + pr: { + url: 'https://github.com/owner/repo/pull/42', + number: 42, + status: PrStatus.Open, + }, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + ...overrides, + } as Feature; +} + +describe('GitHubWebhookService', () => { + let service: GitHubWebhookService; + let mockFeatureRepo: IFeatureRepository; + let mockGitPrService: IGitPrService; + let mockNotificationService: INotificationService; + + let mockExecFn: any; + + beforeEach(() => { + mockFeatureRepo = { + list: vi.fn().mockResolvedValue([]), + findById: vi.fn(), + create: vi.fn(), + update: vi.fn().mockResolvedValue(undefined), + delete: vi.fn(), + } as unknown as IFeatureRepository; + + mockGitPrService = { + getRemoteUrl: vi.fn().mockResolvedValue('https://github.com/owner/repo'), + hasRemote: vi.fn().mockResolvedValue(true), + } as unknown as IGitPrService; + + mockNotificationService = { + notify: vi.fn(), + } as unknown as INotificationService; + + mockExecFn = vi.fn().mockResolvedValue({ stdout: '{}', stderr: '' }); + + service = new GitHubWebhookService( + mockFeatureRepo, + mockGitPrService, + mockNotificationService, + mockExecFn + ); + }); + + describe('validateSignature', () => { + it('should accept valid HMAC-SHA256 signature', () => { + const secret = service.getSecret(); + const payload = '{"action":"opened"}'; + const hmac = createHmac('sha256', secret).update(payload).digest('hex'); + const signature = `sha256=${hmac}`; + + const result = service.validateSignature(payload, signature, secret); + expect(result.valid).toBe(true); + }); + + it('should reject missing signature', () => { + const result = service.validateSignature('payload', '', 'secret'); + expect(result.valid).toBe(false); + expect(result.error).toContain('Missing signature'); + }); + + it('should reject invalid signature format', () => { + const result = service.validateSignature('payload', 'md5=abc', 'secret'); + expect(result.valid).toBe(false); + expect(result.error).toContain('Invalid signature format'); + }); + + it('should reject wrong signature value', () => { + const result = service.validateSignature( + 'payload', + 'sha256=0000000000000000000000000000000000000000000000000000000000000000', + 'secret' + ); + expect(result.valid).toBe(false); + expect(result.error).toContain('mismatch'); + }); + + it('should reject invalid hex encoding', () => { + const result = service.validateSignature('payload', 'sha256=notvalidhex', 'secret'); + expect(result.valid).toBe(false); + }); + }); + + describe('handleEvent — pull_request', () => { + it('should update feature when PR is merged', async () => { + const feature = createMockFeature(); + vi.mocked(mockFeatureRepo.list).mockResolvedValue([feature]); + + await service.handleEvent({ + source: 'github', + eventType: 'pull_request', + deliveryId: 'del-1', + payload: { + action: 'closed', + pull_request: { + number: 42, + html_url: 'https://github.com/owner/repo/pull/42', + merged: true, + head: { ref: 'feat/test' }, + }, + }, + }); + + expect(mockFeatureRepo.update).toHaveBeenCalledWith( + expect.objectContaining({ + lifecycle: SdlcLifecycle.Maintain, + pr: expect.objectContaining({ status: PrStatus.Merged }), + }) + ); + expect(mockNotificationService.notify).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining('merged'), + }) + ); + }); + + it('should update feature when PR is closed without merge', async () => { + const feature = createMockFeature(); + vi.mocked(mockFeatureRepo.list).mockResolvedValue([feature]); + + await service.handleEvent({ + source: 'github', + eventType: 'pull_request', + deliveryId: 'del-2', + payload: { + action: 'closed', + pull_request: { + number: 42, + html_url: 'https://github.com/owner/repo/pull/42', + merged: false, + head: { ref: 'feat/test' }, + }, + }, + }); + + expect(mockFeatureRepo.update).toHaveBeenCalledWith( + expect.objectContaining({ + pr: expect.objectContaining({ status: PrStatus.Closed }), + }) + ); + }); + + it('should match by branch when PR number does not match', async () => { + const feature = createMockFeature({ pr: undefined }); + vi.mocked(mockFeatureRepo.list).mockResolvedValue([feature]); + + await service.handleEvent({ + source: 'github', + eventType: 'pull_request', + deliveryId: 'del-3', + payload: { + action: 'closed', + pull_request: { + number: 99, + html_url: 'https://github.com/owner/repo/pull/99', + merged: true, + head: { ref: 'feat/test' }, + }, + }, + }); + + expect(mockFeatureRepo.update).toHaveBeenCalled(); + }); + + it('should ignore events for unknown features', async () => { + vi.mocked(mockFeatureRepo.list).mockResolvedValue([]); + + await service.handleEvent({ + source: 'github', + eventType: 'pull_request', + deliveryId: 'del-4', + payload: { + action: 'closed', + pull_request: { + number: 999, + html_url: 'https://github.com/owner/repo/pull/999', + merged: true, + head: { ref: 'unknown-branch' }, + }, + }, + }); + + expect(mockFeatureRepo.update).not.toHaveBeenCalled(); + }); + }); + + describe('handleEvent — check_suite', () => { + it('should update CI status on check_suite completion', async () => { + const feature = createMockFeature(); + vi.mocked(mockFeatureRepo.list).mockResolvedValue([feature]); + + await service.handleEvent({ + source: 'github', + eventType: 'check_suite', + deliveryId: 'del-5', + payload: { + action: 'completed', + check_suite: { + conclusion: 'success', + head_branch: 'feat/test', + }, + }, + }); + + expect(mockFeatureRepo.update).toHaveBeenCalledWith( + expect.objectContaining({ + pr: expect.objectContaining({ ciStatus: CiStatus.Success }), + }) + ); + }); + + it('should ignore non-completed check_suite actions', async () => { + await service.handleEvent({ + source: 'github', + eventType: 'check_suite', + deliveryId: 'del-6', + payload: { + action: 'requested', + check_suite: { + conclusion: null, + head_branch: 'feat/test', + }, + }, + }); + + expect(mockFeatureRepo.update).not.toHaveBeenCalled(); + }); + }); + + describe('registerWebhooks', () => { + it('should register webhooks for repos with review features', async () => { + const feature = createMockFeature(); + vi.mocked(mockFeatureRepo.list).mockResolvedValue([feature]); + vi.mocked(mockGitPrService.getRemoteUrl).mockResolvedValue('https://github.com/owner/repo'); + mockExecFn.mockResolvedValue({ stdout: JSON.stringify({ id: 123 }), stderr: '' }); + + await service.registerWebhooks('https://tunnel.trycloudflare.com'); + + expect(mockExecFn).toHaveBeenCalledWith( + 'gh', + expect.arrayContaining([ + 'api', + '--method', + 'POST', + expect.stringContaining('/repos/owner/repo/hooks'), + ]), + expect.objectContaining({ cwd: '/repo/path' }) + ); + }); + + it('should handle registration failure gracefully', async () => { + const feature = createMockFeature(); + vi.mocked(mockFeatureRepo.list).mockResolvedValue([feature]); + vi.mocked(mockGitPrService.getRemoteUrl).mockResolvedValue('https://github.com/owner/repo'); + mockExecFn.mockRejectedValue(new Error('gh api failed')); + + // Should not throw + await expect( + service.registerWebhooks('https://tunnel.trycloudflare.com') + ).resolves.toBeUndefined(); + }); + }); + + describe('removeWebhooks', () => { + it('should remove all registered webhooks', async () => { + const feature = createMockFeature(); + vi.mocked(mockFeatureRepo.list).mockResolvedValue([feature]); + vi.mocked(mockGitPrService.getRemoteUrl).mockResolvedValue('https://github.com/owner/repo'); + mockExecFn.mockResolvedValue({ stdout: JSON.stringify({ id: 456 }), stderr: '' }); + + await service.registerWebhooks('https://tunnel.trycloudflare.com'); + mockExecFn.mockClear(); + + await service.removeWebhooks(); + + expect(mockExecFn).toHaveBeenCalledWith( + 'gh', + expect.arrayContaining(['api', '--method', 'DELETE']), + expect.any(Object) + ); + }); + }); + + describe('updateWebhookUrl', () => { + it('should update URL for all registered webhooks', async () => { + const feature = createMockFeature(); + vi.mocked(mockFeatureRepo.list).mockResolvedValue([feature]); + vi.mocked(mockGitPrService.getRemoteUrl).mockResolvedValue('https://github.com/owner/repo'); + mockExecFn.mockResolvedValue({ stdout: JSON.stringify({ id: 789 }), stderr: '' }); + + await service.registerWebhooks('https://old-url.trycloudflare.com'); + mockExecFn.mockClear(); + + await service.updateWebhookUrl('https://new-url.trycloudflare.com'); + + expect(mockExecFn).toHaveBeenCalledWith( + 'gh', + expect.arrayContaining([ + 'api', + '--method', + 'PATCH', + expect.stringContaining('/repos/owner/repo/hooks/'), + ]), + expect.any(Object) + ); + }); + }); + + describe('delivery history tracking', () => { + it('should record successful deliveries', async () => { + const feature = createMockFeature(); + vi.mocked(mockFeatureRepo.list).mockResolvedValue([feature]); + + await service.handleEvent({ + source: 'github', + eventType: 'pull_request', + deliveryId: 'hist-1', + payload: { + action: 'closed', + pull_request: { + number: 42, + html_url: 'https://github.com/owner/repo/pull/42', + merged: true, + head: { ref: 'feat/test' }, + }, + }, + }); + + const history = service.getDeliveryHistory(); + expect(history).toHaveLength(1); + expect(history[0].deliveryId).toBe('hist-1'); + expect(history[0].status).toBe('success'); + expect(history[0].eventType).toBe('pull_request'); + expect(history[0].durationMs).toBeGreaterThanOrEqual(0); + }); + + it('should record ignored events for unhandled types', async () => { + await service.handleEvent({ + source: 'github', + eventType: 'ping', + deliveryId: 'hist-2', + payload: { zen: 'Keep it logically awesome.' }, + }); + + const history = service.getDeliveryHistory(); + expect(history).toHaveLength(1); + expect(history[0].status).toBe('ignored'); + expect(history[0].statusMessage).toContain('Unhandled event type'); + }); + + it('should return newest deliveries first', async () => { + vi.mocked(mockFeatureRepo.list).mockResolvedValue([]); + + await service.handleEvent({ + source: 'github', + eventType: 'ping', + deliveryId: 'first', + payload: {}, + }); + await service.handleEvent({ + source: 'github', + eventType: 'ping', + deliveryId: 'second', + payload: {}, + }); + + const history = service.getDeliveryHistory(); + expect(history).toHaveLength(2); + expect(history[0].deliveryId).toBe('second'); + expect(history[1].deliveryId).toBe('first'); + }); + + it('should expose registered webhooks via getter', async () => { + const feature = createMockFeature(); + vi.mocked(mockFeatureRepo.list).mockResolvedValue([feature]); + vi.mocked(mockGitPrService.getRemoteUrl).mockResolvedValue('https://github.com/owner/repo'); + mockExecFn.mockResolvedValue({ stdout: JSON.stringify({ id: 555 }), stderr: '' }); + + await service.registerWebhooks('https://tunnel.trycloudflare.com'); + + const registered = service.getRegisteredWebhooks(); + expect(registered).toHaveLength(1); + expect(registered[0].repoFullName).toBe('owner/repo'); + expect(registered[0].webhookId).toBe(555); + }); + }); + + describe('registerWebhookForSingleRepo', () => { + it('should register a webhook and add it to the registered list', async () => { + vi.mocked(mockGitPrService.getRemoteUrl).mockResolvedValue('https://github.com/owner/repo'); + mockExecFn.mockResolvedValue({ stdout: JSON.stringify({ id: 42 }), stderr: '' }); + + const result = await service.registerWebhookForSingleRepo( + '/home/user/repo', + 'https://tunnel.example.com/api/webhooks/github' + ); + + expect(result).not.toBeNull(); + expect(result!.repoFullName).toBe('owner/repo'); + expect(result!.webhookId).toBe(42); + expect(service.getRegisteredWebhooks()).toHaveLength(1); + }); + + it('should no-op when a webhook is already registered for the repo path', async () => { + vi.mocked(mockGitPrService.getRemoteUrl).mockResolvedValue('https://github.com/owner/repo'); + mockExecFn.mockResolvedValue({ stdout: JSON.stringify({ id: 42 }), stderr: '' }); + + await service.registerWebhookForSingleRepo( + '/home/user/repo', + 'https://tunnel.example.com/api/webhooks/github' + ); + const callCountAfterFirst = mockExecFn.mock.calls.length; + + await service.registerWebhookForSingleRepo( + '/home/user/repo', + 'https://tunnel.example.com/api/webhooks/github' + ); + + // No additional gh api calls for the second registration + expect(mockExecFn.mock.calls.length).toBe(callCountAfterFirst); + expect(service.getRegisteredWebhooks()).toHaveLength(1); + }); + + it('should normalize backslash paths before comparing', async () => { + vi.mocked(mockGitPrService.getRemoteUrl).mockResolvedValue('https://github.com/owner/repo'); + mockExecFn.mockResolvedValue({ stdout: JSON.stringify({ id: 42 }), stderr: '' }); + + await service.registerWebhookForSingleRepo( + '/home/user/repo', + 'https://tunnel.example.com/api/webhooks/github' + ); + await service.registerWebhookForSingleRepo( + '\\home\\user\\repo', + 'https://tunnel.example.com/api/webhooks/github' + ); + expect(service.getRegisteredWebhooks()).toHaveLength(1); + }); + + it('should return the existing webhook when already registered', async () => { + vi.mocked(mockGitPrService.getRemoteUrl).mockResolvedValue('https://github.com/owner/repo'); + mockExecFn.mockResolvedValue({ stdout: JSON.stringify({ id: 42 }), stderr: '' }); + + const first = await service.registerWebhookForSingleRepo( + '/home/user/repo', + 'https://tunnel.example.com/api/webhooks/github' + ); + const second = await service.registerWebhookForSingleRepo( + '/home/user/repo', + 'https://tunnel.example.com/api/webhooks/github' + ); + expect(first).toEqual(second); + }); + }); + + describe('removeWebhookForRepo', () => { + it('should remove a webhook from GitHub and the registered list', async () => { + vi.mocked(mockGitPrService.getRemoteUrl).mockResolvedValue('https://github.com/owner/repo'); + mockExecFn.mockResolvedValue({ stdout: JSON.stringify({ id: 42 }), stderr: '' }); + + await service.registerWebhookForSingleRepo( + '/home/user/repo', + 'https://tunnel.example.com/api/webhooks/github' + ); + expect(service.getRegisteredWebhooks()).toHaveLength(1); + mockExecFn.mockClear(); + + await service.removeWebhookForRepo('/home/user/repo'); + expect(service.getRegisteredWebhooks()).toHaveLength(0); + expect(mockExecFn).toHaveBeenCalledWith( + 'gh', + expect.arrayContaining(['api', '--method', 'DELETE']), + expect.any(Object) + ); + }); + + it('should no-op when repo path is not found', async () => { + mockExecFn.mockClear(); + await service.removeWebhookForRepo('/nonexistent/path'); + expect(mockExecFn).not.toHaveBeenCalled(); + }); + + it('should normalize paths when finding webhook to remove', async () => { + vi.mocked(mockGitPrService.getRemoteUrl).mockResolvedValue('https://github.com/owner/repo'); + mockExecFn.mockResolvedValue({ stdout: JSON.stringify({ id: 42 }), stderr: '' }); + + await service.registerWebhookForSingleRepo( + '/home/user/repo', + 'https://tunnel.example.com/api/webhooks/github' + ); + await service.removeWebhookForRepo('\\home\\user\\repo'); + expect(service.getRegisteredWebhooks()).toHaveLength(0); + }); + }); +}); diff --git a/tests/unit/infrastructure/services/webhook/webhook-manager.service.test.ts b/tests/unit/infrastructure/services/webhook/webhook-manager.service.test.ts new file mode 100644 index 000000000..016121a84 --- /dev/null +++ b/tests/unit/infrastructure/services/webhook/webhook-manager.service.test.ts @@ -0,0 +1,307 @@ +/** + * Webhook Manager Service Unit Tests + * + * Tests for the orchestrator that ties tunnel + webhook lifecycle together. + * + * TDD Phase: GREEN + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { + WebhookManagerService, + initializeWebhookManager, + getWebhookManager, + hasWebhookManager, + resetWebhookManager, +} from '@/infrastructure/services/webhook/webhook-manager.service.js'; +import type { ITunnelService } from '@/application/ports/output/services/tunnel-service.interface.js'; +import type { IWebhookService } from '@/application/ports/output/services/webhook-service.interface.js'; + +function createMockTunnelService(): ITunnelService { + return { + start: vi.fn().mockResolvedValue('https://test.trycloudflare.com'), + stop: vi.fn().mockResolvedValue(undefined), + getPublicUrl: vi.fn().mockReturnValue('https://test.trycloudflare.com'), + onUrlChange: vi.fn(), + isRunning: vi.fn().mockReturnValue(true), + }; +} + +function createMockWebhookService(): IWebhookService { + return { + registerWebhooks: vi.fn().mockResolvedValue(undefined), + updateWebhookUrl: vi.fn().mockResolvedValue(undefined), + removeWebhooks: vi.fn().mockResolvedValue(undefined), + validateSignature: vi.fn().mockReturnValue({ valid: true }), + handleEvent: vi.fn().mockResolvedValue(undefined), + }; +} + +describe('WebhookManagerService', () => { + let tunnelService: ReturnType; + let webhookService: ReturnType; + let manager: WebhookManagerService; + + beforeEach(() => { + tunnelService = createMockTunnelService(); + webhookService = createMockWebhookService(); + manager = new WebhookManagerService(tunnelService, webhookService); + }); + + describe('start', () => { + it('should start tunnel and register webhooks', async () => { + await manager.start(3000); + + expect(tunnelService.start).toHaveBeenCalledWith(3000); + expect(tunnelService.onUrlChange).toHaveBeenCalled(); + expect(webhookService.registerWebhooks).toHaveBeenCalledWith( + 'https://test.trycloudflare.com' + ); + expect(manager.isRunning()).toBe(true); + }); + + it('should register URL change handler that updates webhooks', async () => { + await manager.start(3000); + + // Get the URL change handler that was registered + const onUrlChange = vi.mocked(tunnelService.onUrlChange); + expect(onUrlChange).toHaveBeenCalledTimes(1); + const handler = onUrlChange.mock.calls[0][0]; + + // Simulate URL change + await handler('https://new-url.trycloudflare.com'); + + expect(webhookService.updateWebhookUrl).toHaveBeenCalledWith( + 'https://new-url.trycloudflare.com' + ); + }); + + it('should not throw if tunnel start fails (graceful fallback)', async () => { + vi.mocked(tunnelService.start).mockRejectedValue(new Error("'cloudflared' not found")); + + // Should not throw + await expect(manager.start(3000)).resolves.toBeUndefined(); + expect(manager.isRunning()).toBe(false); + }); + + it('should clean up tunnel if webhook registration fails', async () => { + vi.mocked(webhookService.registerWebhooks).mockRejectedValue(new Error('GitHub API error')); + + await expect(manager.start(3000)).resolves.toBeUndefined(); + expect(tunnelService.stop).toHaveBeenCalled(); + expect(manager.isRunning()).toBe(false); + }); + + it('should be idempotent when already running', async () => { + await manager.start(3000); + await manager.start(3000); + + expect(tunnelService.start).toHaveBeenCalledTimes(1); + }); + }); + + describe('stop', () => { + it('should remove webhooks and stop tunnel', async () => { + await manager.start(3000); + await manager.stop(); + + expect(webhookService.removeWebhooks).toHaveBeenCalled(); + expect(tunnelService.stop).toHaveBeenCalled(); + expect(manager.isRunning()).toBe(false); + }); + + it('should handle webhook removal failure gracefully', async () => { + await manager.start(3000); + vi.mocked(webhookService.removeWebhooks).mockRejectedValue(new Error('API error')); + + await expect(manager.stop()).resolves.toBeUndefined(); + expect(tunnelService.stop).toHaveBeenCalled(); + }); + + it('should be safe to call stop when not running', async () => { + await expect(manager.stop()).resolves.toBeUndefined(); + }); + }); + + describe('getTunnelUrl', () => { + it('should return tunnel URL when running', async () => { + await manager.start(3000); + expect(manager.getTunnelUrl()).toBe('https://test.trycloudflare.com'); + }); + }); + + describe('getStatus', () => { + it('should return inactive status when not started', () => { + const status = manager.getStatus(); + expect(status.running).toBe(false); + expect(status.startedAt).toBeNull(); + }); + + it('should return active status with tunnel info when running', async () => { + await manager.start(3000); + const status = manager.getStatus(); + + expect(status.running).toBe(true); + expect(status.startedAt).not.toBeNull(); + expect(status.tunnel.connected).toBe(true); + expect(status.tunnel.publicUrl).toBe('https://test.trycloudflare.com'); + }); + + it('should return delivery statistics', async () => { + await manager.start(3000); + const status = manager.getStatus(); + + expect(status.webhooks.totalDeliveries).toBe(0); + expect(status.webhooks.successCount).toBe(0); + expect(status.webhooks.errorCount).toBe(0); + expect(status.webhooks.ignoredCount).toBe(0); + }); + }); + + describe('getDeliveryHistory', () => { + it('should return empty array when webhook service has no delivery tracking', async () => { + await manager.start(3000); + const history = manager.getDeliveryHistory(); + expect(history).toEqual([]); + }); + }); +}); + +describe('Singleton accessors', () => { + afterEach(() => { + resetWebhookManager(); + }); + + it('should initialize and retrieve singleton', () => { + const tunnel = createMockTunnelService(); + const webhook = createMockWebhookService(); + + expect(hasWebhookManager()).toBe(false); + + initializeWebhookManager(tunnel, webhook); + + expect(hasWebhookManager()).toBe(true); + expect(getWebhookManager()).toBeInstanceOf(WebhookManagerService); + }); + + it('should throw on double initialization', () => { + const tunnel = createMockTunnelService(); + const webhook = createMockWebhookService(); + + initializeWebhookManager(tunnel, webhook); + + expect(() => initializeWebhookManager(tunnel, webhook)).toThrow('already initialized'); + }); + + it('should throw when getting uninitialized manager', () => { + expect(() => getWebhookManager()).toThrow('not initialized'); + }); + + it('should reset cleanly', () => { + const tunnel = createMockTunnelService(); + const webhook = createMockWebhookService(); + + initializeWebhookManager(tunnel, webhook); + resetWebhookManager(); + + expect(hasWebhookManager()).toBe(false); + }); +}); + +describe('Per-repo webhook methods', () => { + function createMockWebhookServiceWithSingleRepo() { + return { + ...createMockWebhookService(), + registerWebhookForSingleRepo: vi + .fn() + .mockResolvedValue({ repoFullName: 'owner/repo', webhookId: 42, repositoryPath: '/repo' }), + removeWebhookForRepo: vi.fn().mockResolvedValue(undefined), + getRegisteredWebhooks: vi.fn().mockReturnValue([]), + getDeliveryHistory: vi.fn().mockReturnValue([]), + }; + } + + describe('enableWebhookForRepo', () => { + it('should return error when tunnel is not running', async () => { + const tunnel = createMockTunnelService(); + vi.mocked(tunnel.isRunning).mockReturnValue(false); + vi.mocked(tunnel.getPublicUrl).mockReturnValue(null); + const webhook = createMockWebhookServiceWithSingleRepo(); + const manager = new WebhookManagerService(tunnel, webhook); + + const result = await manager.enableWebhookForRepo('/repo'); + expect(result).toEqual({ success: false, error: 'tunnel_not_connected' }); + expect(webhook.registerWebhookForSingleRepo).not.toHaveBeenCalled(); + }); + + it('should register webhook and return success when tunnel is running', async () => { + const tunnel = createMockTunnelService(); + const webhook = createMockWebhookServiceWithSingleRepo(); + const manager = new WebhookManagerService(tunnel, webhook); + + const result = await manager.enableWebhookForRepo('/repo'); + expect(result.success).toBe(true); + expect(result.webhook).toBeDefined(); + expect(webhook.registerWebhookForSingleRepo).toHaveBeenCalledWith( + '/repo', + 'https://test.trycloudflare.com/api/webhooks/github' + ); + }); + + it('should return error when registration throws', async () => { + const tunnel = createMockTunnelService(); + const webhook = createMockWebhookServiceWithSingleRepo(); + webhook.registerWebhookForSingleRepo.mockRejectedValue(new Error('gh api failed')); + const manager = new WebhookManagerService(tunnel, webhook); + + const result = await manager.enableWebhookForRepo('/repo'); + expect(result.success).toBe(false); + expect(result.error).toBe('gh api failed'); + }); + }); + + describe('disableWebhookForRepo', () => { + it('should delegate to webhookService.removeWebhookForRepo', async () => { + const tunnel = createMockTunnelService(); + const webhook = createMockWebhookServiceWithSingleRepo(); + const manager = new WebhookManagerService(tunnel, webhook); + + const result = await manager.disableWebhookForRepo('/repo'); + expect(result).toEqual({ success: true }); + expect(webhook.removeWebhookForRepo).toHaveBeenCalledWith('/repo'); + }); + + it('should return error when removal throws', async () => { + const tunnel = createMockTunnelService(); + const webhook = createMockWebhookServiceWithSingleRepo(); + webhook.removeWebhookForRepo.mockRejectedValue(new Error('api error')); + const manager = new WebhookManagerService(tunnel, webhook); + + const result = await manager.disableWebhookForRepo('/repo'); + expect(result.success).toBe(false); + expect(result.error).toBe('api error'); + }); + }); + + describe('isWebhookEnabledForRepo', () => { + it('should return false when repo has no webhook', () => { + const tunnel = createMockTunnelService(); + const webhook = createMockWebhookServiceWithSingleRepo(); + const manager = new WebhookManagerService(tunnel, webhook); + + expect(manager.isWebhookEnabledForRepo('/repo')).toBe(false); + }); + + it('should return true when repo has a webhook (normalized path)', () => { + const tunnel = createMockTunnelService(); + const webhook = createMockWebhookServiceWithSingleRepo(); + webhook.getRegisteredWebhooks.mockReturnValue([ + { repoFullName: 'owner/repo', webhookId: 42, repositoryPath: '/home/user/repo' }, + ]); + const manager = new WebhookManagerService(tunnel, webhook); + + expect(manager.isWebhookEnabledForRepo('/home/user/repo')).toBe(true); + expect(manager.isWebhookEnabledForRepo('\\home\\user\\repo')).toBe(true); + }); + }); +});