diff --git a/admin/app/webhook-tester/components/PayloadEditor.tsx b/admin/app/webhook-tester/components/PayloadEditor.tsx index aa0c9852..971d9578 100644 --- a/admin/app/webhook-tester/components/PayloadEditor.tsx +++ b/admin/app/webhook-tester/components/PayloadEditor.tsx @@ -3,6 +3,7 @@ import React, { useRef } from 'react'; import { useWebhookTester } from '../context'; import { DEFAULT_PAYLOAD } from '../types'; +import { RetryIntervalInput } from './RetryIntervalInput'; // Minimal syntax highlighting for JSON in a textarea overlay approach function highlight(json: string): string { @@ -148,6 +149,9 @@ export function PayloadEditor() { )} + + {/* Retry interval configuration */} + ); } diff --git a/admin/app/webhook-tester/components/RetryIntervalInput.tsx b/admin/app/webhook-tester/components/RetryIntervalInput.tsx new file mode 100644 index 00000000..e58b1ee6 --- /dev/null +++ b/admin/app/webhook-tester/components/RetryIntervalInput.tsx @@ -0,0 +1,175 @@ +'use client'; + +import React, { useId } from 'react'; +import { useWebhookTester } from '../context'; + +/** Preset suggestions (seconds) with human-readable labels */ +const SUGGESTIONS: { label: string; value: number }[] = [ + { label: '10s', value: 10 }, + { label: '30s', value: 30 }, + { label: '1m', value: 60 }, + { label: '5m', value: 300 }, + { label: '15m', value: 900 }, + { label: '1h', value: 3600 }, +]; + +const MIN = 10; +const MAX = 3600; + +function formatSeconds(s: number): string { + if (s < 60) return `${s}s`; + if (s < 3600) return `${Math.floor(s / 60)}m ${s % 60 > 0 ? `${s % 60}s` : ''}`.trim(); + return '1h'; +} + +export function RetryIntervalInput() { + const { + selectedWebhook, + retryInterval, + setRetryInterval, + retryIntervalError, + isSavingRetryInterval, + saveRetryInterval, + retrySaveSuccess, + } = useWebhookTester(); + + const inputId = useId(); + + const handleInputChange = (e: React.ChangeEvent) => { + const raw = e.target.value; + const parsed = parseInt(raw, 10); + setRetryInterval(isNaN(parsed) ? 0 : parsed); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') saveRetryInterval(); + }; + + const isDisabled = !selectedWebhook || isSavingRetryInterval; + const hasError = !!retryIntervalError; + + return ( +
+ {/* Label row */} +
+ + {retrySaveSuccess && ( + + ✓ saved + + )} + {hasError && ( + + {retryIntervalError} + + )} +
+ + {/* Suggestion chips */} +
+ {SUGGESTIONS.map(({ label, value }) => { + const isActive = retryInterval === value; + return ( + + ); + })} +
+ + {/* Number input + range slider + save button */} +
+
+ {/* Numeric input */} +
+ + + {retryInterval >= MIN && retryInterval <= MAX + ? formatSeconds(retryInterval) + : 'seconds'} + +
+ + {/* Range slider */} + setRetryInterval(parseInt(e.target.value, 10))} + disabled={isDisabled} + aria-label="Retry interval slider" + className="w-full accent-blue-500 disabled:opacity-40 disabled:cursor-not-allowed" + /> +
+ {MIN}s + 1h +
+
+ + {/* Save button */} + +
+ + {/* Accessible error description */} + {hasError && ( +

+ {retryIntervalError} +

+ )} +
+ ); +} diff --git a/admin/app/webhook-tester/components/__tests__/RetryIntervalInput.test.tsx b/admin/app/webhook-tester/components/__tests__/RetryIntervalInput.test.tsx new file mode 100644 index 00000000..45fda8af --- /dev/null +++ b/admin/app/webhook-tester/components/__tests__/RetryIntervalInput.test.tsx @@ -0,0 +1,239 @@ +/** + * Tests for RetryIntervalInput component + * + * Covers: + * - Renders with current retryInterval value + * - Suggestion chips update the value + * - Range validation (below min, above max, valid) + * - Save button calls saveRetryInterval + * - Disabled state when no webhook selected + * - Success feedback after save + * - Error message display + */ + +import React from 'react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { RetryIntervalInput } from '../RetryIntervalInput'; +import * as contextModule from '../../context'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeContextValue(overrides: Partial> = {}) { + return { + webhooks: [], + isLoadingWebhooks: false, + fetchWebhooks: vi.fn(), + selectedWebhook: { + id: 1, + contract_id: 'CABC', + event_type: 'swap', + target_url: 'https://example.com/hook', + is_active: true, + status: 'active' as const, + created_at: '2024-01-01T00:00:00Z', + last_triggered: null, + failure_count: 0, + timeout_seconds: 10, + retry_interval_seconds: 60, + }, + setSelectedWebhook: vi.fn(), + payload: '{}', + setPayload: vi.fn(), + payloadError: null, + isSending: false, + sendTest: vi.fn(), + response: null, + sendError: null, + history: [], + clearHistory: vi.fn(), + selectHistoryEntry: vi.fn(), + retryInterval: 60, + setRetryInterval: vi.fn(), + retryIntervalError: null, + isSavingRetryInterval: false, + saveRetryInterval: vi.fn(), + retrySaveSuccess: false, + ...overrides, + }; +} + +function renderWithContext(overrides: Partial> = {}) { + vi.spyOn(contextModule, 'useWebhookTester').mockReturnValue(makeContextValue(overrides)); + return render(); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('RetryIntervalInput', () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + // --- Rendering --- + + it('renders the label', () => { + renderWithContext(); + expect(screen.getByText(/retry interval/i)).toBeInTheDocument(); + }); + + it('renders the numeric input with the current retryInterval value', () => { + renderWithContext({ retryInterval: 300 }); + const input = screen.getByRole('spinbutton'); + expect(input).toHaveValue(300); + }); + + it('renders all suggestion chips', () => { + renderWithContext(); + const expectedLabels = ['10s', '30s', '1m', '5m', '15m', '1h']; + for (const label of expectedLabels) { + expect(screen.getByRole('button', { name: label })).toBeInTheDocument(); + } + }); + + it('renders the Save button', () => { + renderWithContext(); + expect(screen.getByRole('button', { name: /save retry interval/i })).toBeInTheDocument(); + }); + + // --- Suggestion chips --- + + it('calls setRetryInterval with the chip value when a chip is clicked', async () => { + const setRetryInterval = vi.fn(); + renderWithContext({ setRetryInterval }); + await userEvent.click(screen.getByRole('button', { name: '5m' })); + expect(setRetryInterval).toHaveBeenCalledWith(300); + }); + + it('marks the active chip with aria-pressed=true', () => { + renderWithContext({ retryInterval: 60 }); + const activeChip = screen.getByRole('button', { name: '1m' }); + expect(activeChip).toHaveAttribute('aria-pressed', 'true'); + }); + + it('marks non-active chips with aria-pressed=false', () => { + renderWithContext({ retryInterval: 60 }); + const inactiveChip = screen.getByRole('button', { name: '5m' }); + expect(inactiveChip).toHaveAttribute('aria-pressed', 'false'); + }); + + // --- Numeric input --- + + it('calls setRetryInterval when the number input changes', async () => { + const setRetryInterval = vi.fn(); + renderWithContext({ setRetryInterval }); + const input = screen.getByRole('spinbutton'); + await userEvent.clear(input); + await userEvent.type(input, '120'); + expect(setRetryInterval).toHaveBeenLastCalledWith(120); + }); + + it('has min=10 and max=3600 attributes on the number input', () => { + renderWithContext(); + const input = screen.getByRole('spinbutton'); + expect(input).toHaveAttribute('min', '10'); + expect(input).toHaveAttribute('max', '3600'); + }); + + // --- Range validation --- + + it('shows error message when retryIntervalError is set', () => { + renderWithContext({ retryIntervalError: 'Must be between 10 and 3600 seconds' }); + expect(screen.getByRole('alert')).toHaveTextContent('Must be between 10 and 3600 seconds'); + }); + + it('marks the input as aria-invalid when there is an error', () => { + renderWithContext({ retryIntervalError: 'Must be between 10 and 3600 seconds' }); + const input = screen.getByRole('spinbutton'); + expect(input).toHaveAttribute('aria-invalid', 'true'); + }); + + it('does not show error when retryIntervalError is null', () => { + renderWithContext({ retryIntervalError: null }); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + }); + + it('disables Save button when there is a validation error', () => { + renderWithContext({ retryIntervalError: 'Must be between 10 and 3600 seconds' }); + const saveBtn = screen.getByRole('button', { name: /save retry interval/i }); + expect(saveBtn).toBeDisabled(); + }); + + // --- Save behaviour --- + + it('calls saveRetryInterval when Save is clicked', async () => { + const saveRetryInterval = vi.fn(); + renderWithContext({ saveRetryInterval }); + await userEvent.click(screen.getByRole('button', { name: /save retry interval/i })); + expect(saveRetryInterval).toHaveBeenCalledOnce(); + }); + + it('calls saveRetryInterval when Enter is pressed in the input', async () => { + const saveRetryInterval = vi.fn(); + renderWithContext({ saveRetryInterval }); + const input = screen.getByRole('spinbutton'); + fireEvent.keyDown(input, { key: 'Enter' }); + expect(saveRetryInterval).toHaveBeenCalledOnce(); + }); + + it('shows saving spinner while isSavingRetryInterval is true', () => { + renderWithContext({ isSavingRetryInterval: true }); + expect(screen.getByText(/saving/i)).toBeInTheDocument(); + }); + + it('disables inputs while saving', () => { + renderWithContext({ isSavingRetryInterval: true }); + const input = screen.getByRole('spinbutton'); + expect(input).toBeDisabled(); + }); + + // --- Success feedback --- + + it('shows success message when retrySaveSuccess is true', () => { + renderWithContext({ retrySaveSuccess: true }); + expect(screen.getByRole('status')).toHaveTextContent('✓ saved'); + }); + + it('does not show success message when retrySaveSuccess is false', () => { + renderWithContext({ retrySaveSuccess: false }); + expect(screen.queryByRole('status')).not.toBeInTheDocument(); + }); + + // --- Disabled state (no webhook selected) --- + + it('disables all inputs when no webhook is selected', () => { + renderWithContext({ selectedWebhook: null }); + const input = screen.getByRole('spinbutton'); + expect(input).toBeDisabled(); + const saveBtn = screen.getByRole('button', { name: /save retry interval/i }); + expect(saveBtn).toBeDisabled(); + }); + + it('disables suggestion chips when no webhook is selected', () => { + renderWithContext({ selectedWebhook: null }); + const chips = screen.getAllByRole('button', { name: /^(10s|30s|1m|5m|15m|1h)$/ }); + for (const chip of chips) { + expect(chip).toBeDisabled(); + } + }); + + // --- Range slider --- + + it('renders a range slider', () => { + renderWithContext(); + const slider = screen.getByRole('slider'); + expect(slider).toBeInTheDocument(); + }); + + it('slider value is clamped to valid range', () => { + // Value below min should clamp to 10 + renderWithContext({ retryInterval: 5 }); + const slider = screen.getByRole('slider'); + expect(Number(slider.getAttribute('value'))).toBe(10); + }); +}); diff --git a/admin/app/webhook-tester/context.tsx b/admin/app/webhook-tester/context.tsx index bf7e6a18..83a29360 100644 --- a/admin/app/webhook-tester/context.tsx +++ b/admin/app/webhook-tester/context.tsx @@ -35,6 +35,14 @@ interface WebhookTesterContextType { history: HistoryEntry[]; clearHistory: () => void; selectHistoryEntry: (entry: HistoryEntry) => void; + + // Retry interval + retryInterval: number; + setRetryInterval: (v: number) => void; + retryIntervalError: string | null; + isSavingRetryInterval: boolean; + saveRetryInterval: () => Promise; + retrySaveSuccess: boolean; } const WebhookTesterContext = createContext(undefined); @@ -52,6 +60,12 @@ export function WebhookTesterProvider({ children }: { children: ReactNode }) { const [requestHeaders, setRequestHeaders] = useState>({}); const lastRequestRef = useRef<{ webhook: WebhookSubscription; payload: string } | null>(null); + // Retry interval state + const [retryInterval, setRetryIntervalState] = useState(60); + const [retryIntervalError, setRetryIntervalError] = useState(null); + const [isSavingRetryInterval, setIsSavingRetryInterval] = useState(false); + const [retrySaveSuccess, setRetrySaveSuccess] = useState(false); + const fetchWebhooks = useCallback(async () => { setIsLoadingWebhooks(true); try { @@ -74,6 +88,7 @@ export function WebhookTesterProvider({ children }: { children: ReactNode }) { setSelectedWebhookState(w); setResponse(null); setSendError(null); + setRetrySaveSuccess(false); if (w) { try { const parsed = JSON.parse(payload); @@ -183,6 +198,47 @@ export function WebhookTesterProvider({ children }: { children: ReactNode }) { const clearHistory = useCallback(() => setHistory([]), []); + const setRetryInterval = useCallback((v: number) => { + setRetryIntervalState(v); + if (v < 10 || v > 3600) { + setRetryIntervalError('Must be between 10 and 3600 seconds'); + } else { + setRetryIntervalError(null); + } + setRetrySaveSuccess(false); + }, []); + + const saveRetryInterval = useCallback(async () => { + if (!selectedWebhook) return; + if (retryInterval < 10 || retryInterval > 3600) { + setRetryIntervalError('Must be between 10 and 3600 seconds'); + return; + } + setIsSavingRetryInterval(true); + setRetrySaveSuccess(false); + try { + const res = await fetch( + `${BASE_URL}/api/ingest/webhooks/${selectedWebhook.id}/`, + { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ retry_interval_seconds: retryInterval }), + } + ); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const updated: WebhookSubscription = await res.json(); + // Update the webhook in the list and selected state + setWebhooks(prev => prev.map(w => w.id === updated.id ? updated : w)); + setSelectedWebhookState(updated); + setRetrySaveSuccess(true); + setTimeout(() => setRetrySaveSuccess(false), 3000); + } catch (err) { + setRetryIntervalError(err instanceof Error ? err.message : 'Save failed'); + } finally { + setIsSavingRetryInterval(false); + } + }, [selectedWebhook, retryInterval]); + const selectHistoryEntry = useCallback((entry: HistoryEntry) => { setPayloadState(entry.payload); setPayloadError(null); @@ -201,6 +257,8 @@ export function WebhookTesterProvider({ children }: { children: ReactNode }) { isSending, sendTest, retryLastRequest, response, sendError, requestHeaders, history, clearHistory, selectHistoryEntry, + retryInterval, setRetryInterval, retryIntervalError, + isSavingRetryInterval, saveRetryInterval, retrySaveSuccess, }}> {children} diff --git a/admin/app/webhook-tester/types.ts b/admin/app/webhook-tester/types.ts index f68109a5..3359e7c3 100644 --- a/admin/app/webhook-tester/types.ts +++ b/admin/app/webhook-tester/types.ts @@ -10,6 +10,8 @@ export interface WebhookSubscription { created_at: string; last_triggered: string | null; failure_count: number; + timeout_seconds: number; + retry_interval_seconds: number; } export interface TestResponse { diff --git a/admin/package.json b/admin/package.json index fa0c94e6..12b7ce2d 100644 --- a/admin/package.json +++ b/admin/package.json @@ -6,7 +6,9 @@ "dev": "next dev", "build": "next build", "start": "next start", - "lint": "eslint" + "lint": "eslint", + "test": "vitest --run", + "test:watch": "vitest" }, "dependencies": { "next": "16.1.6", @@ -16,12 +18,18 @@ }, "devDependencies": { "@tailwindcss/postcss": "^4", + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/react": "^16.3.0", + "@testing-library/user-event": "^14.5.2", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", + "@vitejs/plugin-react": "^4.3.4", "eslint": "^9", "eslint-config-next": "16.1.6", + "jsdom": "^26.1.0", "tailwindcss": "^4", - "typescript": "^5" + "typescript": "^5", + "vitest": "^3.2.3" } } diff --git a/admin/vitest.config.ts b/admin/vitest.config.ts new file mode 100644 index 00000000..535909c5 --- /dev/null +++ b/admin/vitest.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from 'vitest/config'; +import react from '@vitejs/plugin-react'; + +export default defineConfig({ + plugins: [react()], + test: { + environment: 'jsdom', + globals: true, + setupFiles: ['./vitest.setup.ts'], + }, +}); diff --git a/admin/vitest.setup.ts b/admin/vitest.setup.ts new file mode 100644 index 00000000..7b0828bf --- /dev/null +++ b/admin/vitest.setup.ts @@ -0,0 +1 @@ +import '@testing-library/jest-dom'; diff --git a/django-backend/soroscan/ingest/migrations/0027_webhooksubscription_retry_interval_seconds.py b/django-backend/soroscan/ingest/migrations/0027_webhooksubscription_retry_interval_seconds.py new file mode 100644 index 00000000..0fa9b27d --- /dev/null +++ b/django-backend/soroscan/ingest/migrations/0027_webhooksubscription_retry_interval_seconds.py @@ -0,0 +1,24 @@ +from django.db import migrations, models +import django.core.validators + + +class Migration(migrations.Migration): + + dependencies = [ + ("ingest", "0026_merge_20260329_0027"), + ] + + operations = [ + migrations.AddField( + model_name="webhooksubscription", + name="retry_interval_seconds", + field=models.IntegerField( + default=60, + validators=[ + django.core.validators.MinValueValidator(10), + django.core.validators.MaxValueValidator(3600), + ], + help_text="Minimum interval between retry attempts in seconds (10-3600, default: 60)", + ), + ), + ] diff --git a/django-backend/soroscan/ingest/serializers.py b/django-backend/soroscan/ingest/serializers.py index 165d6469..82691ba6 100644 --- a/django-backend/soroscan/ingest/serializers.py +++ b/django-backend/soroscan/ingest/serializers.py @@ -286,8 +286,10 @@ class Meta: "created_at", "last_triggered", "failure_count", + "timeout_seconds", + "retry_interval_seconds", ] - read_only_fields = ["id", "contract_id", "created_at", "last_triggered", "failure_count"] + read_only_fields = ["id", "contract_id", "created_at", "last_triggered", "failure_count", "status"] extra_kwargs = { "secret": {"write_only": True}, } diff --git a/django-backend/soroscan/ingest/tasks.py b/django-backend/soroscan/ingest/tasks.py index a8f7bbf0..0a3432c4 100644 --- a/django-backend/soroscan/ingest/tasks.py +++ b/django-backend/soroscan/ingest/tasks.py @@ -906,6 +906,9 @@ def dispatch_webhook(self, subscription_id: int, event_id: int) -> bool: countdown = int(retry_after) except (ValueError, TypeError): pass + # Fall back to the subscription's configured retry interval + if countdown is None: + countdown = webhook.retry_interval_seconds # Check if we've exhausted retries if self.request.retries >= self.max_retries: diff --git a/django-backend/soroscan/ingest/tests/test_tasks.py b/django-backend/soroscan/ingest/tests/test_tasks.py index f3f3ea38..6c18d96b 100644 --- a/django-backend/soroscan/ingest/tests/test_tasks.py +++ b/django-backend/soroscan/ingest/tests/test_tasks.py @@ -650,6 +650,102 @@ def test_timeout_field_validates_min_max(self, contract): webhook.full_clean() +@pytest.mark.django_db +class TestRetryIntervalSeconds: + """Tests for the configurable retry_interval_seconds field.""" + + def test_default_retry_interval_is_60(self, webhook): + """Verify default retry_interval_seconds is 60.""" + assert webhook.retry_interval_seconds == 60 + + def test_retry_interval_field_validates_min_max(self, contract): + """Verify MinValueValidator(10) and MaxValueValidator(3600).""" + from django.core.exceptions import ValidationError + + # Valid: 10 + webhook = WebhookSubscription( + contract=contract, + target_url="https://example.com/webhook", + secret="test-secret-hex", + retry_interval_seconds=10, + ) + webhook.full_clean() # Should not raise + + # Valid: 3600 + webhook.retry_interval_seconds = 3600 + webhook.full_clean() # Should not raise + + # Invalid: 9 + webhook.retry_interval_seconds = 9 + with pytest.raises(ValidationError): + webhook.full_clean() + + # Invalid: 3601 + webhook.retry_interval_seconds = 3601 + with pytest.raises(ValidationError): + webhook.full_clean() + + @responses.activate + def test_retry_uses_subscription_retry_interval(self, webhook, event): + """Verify that self.retry is called with countdown=retry_interval_seconds on 5xx.""" + webhook.retry_interval_seconds = 120 + webhook.save() + + responses.add(responses.POST, webhook.target_url, status=500) + + with pytest.raises(Retry) as exc_info: + dispatch_webhook.apply(args=[webhook.id, event.id], throw=True) + + assert exc_info.value.when == 120 + + @responses.activate + def test_retry_interval_used_for_network_errors(self, webhook, event): + """Verify that network errors also use retry_interval_seconds as countdown.""" + webhook.retry_interval_seconds = 90 + webhook.save() + + responses.add( + responses.POST, webhook.target_url, + body=requests.exceptions.ConnectionError("Connection refused"), + ) + + with pytest.raises(Retry) as exc_info: + dispatch_webhook.apply(args=[webhook.id, event.id], throw=True) + + assert exc_info.value.when == 90 + + @responses.activate + def test_429_retry_after_header_overrides_interval(self, webhook, event): + """Retry-After header takes precedence over retry_interval_seconds for 429.""" + webhook.retry_interval_seconds = 90 + webhook.save() + + responses.add( + responses.POST, webhook.target_url, + status=429, + headers={"Retry-After": "300"}, + ) + + with pytest.raises(Retry) as exc_info: + dispatch_webhook.apply(args=[webhook.id, event.id], throw=True) + + # Retry-After wins + assert exc_info.value.when == 300 + + @responses.activate + def test_429_without_retry_after_uses_interval(self, webhook, event): + """When no Retry-After header, retry_interval_seconds is used for 429.""" + webhook.retry_interval_seconds = 150 + webhook.save() + + responses.add(responses.POST, webhook.target_url, status=429) + + with pytest.raises(Retry) as exc_info: + dispatch_webhook.apply(args=[webhook.id, event.id], throw=True) + + assert exc_info.value.when == 150 + + @pytest.mark.django_db class TestProcessNewEvent: @responses.activate