Skip to content

Commit 23841b8

Browse files
committed
Add remote webhook management
1 parent 434ceba commit 23841b8

18 files changed

Lines changed: 1922 additions & 14 deletions

src/App.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import CompetitionPerson from './pages/Competition/Person';
2121
import CompetitionPersonalBests from './pages/Competition/Person/PersonalBests';
2222
import { PsychSheetEvent } from './pages/Competition/PsychSheet/PsychSheetEvent';
2323
import CompetitionRemote from './pages/Competition/Remote';
24+
import CompetitionRemoteWebhooks from './pages/Competition/Remote/Webhooks';
2425
import CompetitionResults from './pages/Competition/Results';
2526
import {
2627
CompetitionActivity,
@@ -152,10 +153,12 @@ const Navigation = () => {
152153

153154
<Route path="admin" element={<CompetitionAdmin />} />
154155
<Route path="admin/remote" element={<CompetitionRemote />} />
156+
<Route path="admin/webhooks" element={<CompetitionRemoteWebhooks />} />
155157
<Route path="admin/scramblers" element={<CompetitionScramblerSchedule />} />
156158
<Route path="admin/stats" element={<CompetitionStats />} />
157159
<Route path="admin/sum-of-ranks" element={<CompetitionSumOfRanks />} />
158160
<Route path="remote" element={<CompetitionRedirect to="admin/remote" />} />
161+
<Route path="webhooks" element={<CompetitionRedirect to="admin/webhooks" />} />
159162
<Route path="scramblers" element={<CompetitionRedirect to="admin/scramblers" />} />
160163
<Route path="stream" element={<CompetitionStreamSchedule />} />
161164
<Route path="information" element={<CompetitionInformation />} />
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { useNotifyCompRemoteWebhooks } from './useNotifyCompRemoteWebhooks';
Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
import { MockedProvider, MockedResponse } from '@apollo/client/testing';
2+
import { act, renderHook, waitFor } from '@testing-library/react';
3+
import { PropsWithChildren } from 'react';
4+
import {
5+
CreateRemoteWebhookDocument,
6+
DeleteRemoteWebhookDocument,
7+
RemoteWebhooksDocument,
8+
TestEditingRemoteWebhookDocument,
9+
TestRemoteWebhookDocument,
10+
UpdateRemoteWebhookDocument,
11+
} from '@/lib/notifyCompRemoteGraphql';
12+
import { useNotifyCompRemoteWebhooks } from './useNotifyCompRemoteWebhooks';
13+
14+
const competitionId = 'ExampleComp2026';
15+
16+
const webhooksMock = (webhooks: unknown[]): MockedResponse => ({
17+
request: {
18+
query: RemoteWebhooksDocument,
19+
variables: { competitionId },
20+
},
21+
result: {
22+
data: {
23+
competition: {
24+
__typename: 'Competition',
25+
id: competitionId,
26+
webhooks,
27+
},
28+
},
29+
},
30+
});
31+
32+
const createWrapper = (mocks: MockedResponse[]) =>
33+
function MockedApolloWrapper({ children }: PropsWithChildren) {
34+
return <MockedProvider mocks={mocks}>{children}</MockedProvider>;
35+
};
36+
37+
describe('useNotifyCompRemoteWebhooks', () => {
38+
it('loads competition webhooks', async () => {
39+
const { result } = renderHook(() => useNotifyCompRemoteWebhooks({ competitionId }), {
40+
wrapper: createWrapper([
41+
webhooksMock([
42+
{
43+
__typename: 'Webhook',
44+
id: 1,
45+
method: 'POST',
46+
url: 'https://example.com/notify',
47+
},
48+
]),
49+
]),
50+
});
51+
52+
await waitFor(() => {
53+
expect(result.current.webhooks).toHaveLength(1);
54+
});
55+
56+
expect(result.current.webhooks[0]).toMatchObject({
57+
method: 'POST',
58+
url: 'https://example.com/notify',
59+
});
60+
});
61+
62+
it('creates, updates, and deletes webhooks with NotifyComp variables', async () => {
63+
const createVariables = {
64+
competitionId,
65+
webhook: {
66+
method: 'POST' as const,
67+
url: 'https://example.com/created',
68+
},
69+
};
70+
const updateVariables = {
71+
id: 4,
72+
webhook: {
73+
method: 'GET' as const,
74+
url: 'https://example.com/updated',
75+
},
76+
};
77+
78+
const { result } = renderHook(() => useNotifyCompRemoteWebhooks({ competitionId }), {
79+
wrapper: createWrapper([
80+
webhooksMock([]),
81+
{
82+
request: {
83+
query: CreateRemoteWebhookDocument,
84+
variables: createVariables,
85+
},
86+
result: {
87+
data: {
88+
createWebhook: {
89+
__typename: 'Webhook',
90+
id: 4,
91+
...createVariables.webhook,
92+
},
93+
},
94+
},
95+
},
96+
webhooksMock([
97+
{
98+
__typename: 'Webhook',
99+
id: 4,
100+
...createVariables.webhook,
101+
},
102+
]),
103+
{
104+
request: {
105+
query: UpdateRemoteWebhookDocument,
106+
variables: updateVariables,
107+
},
108+
result: {
109+
data: {
110+
updateWebhook: {
111+
__typename: 'Webhook',
112+
id: 4,
113+
...updateVariables.webhook,
114+
},
115+
},
116+
},
117+
},
118+
webhooksMock([
119+
{
120+
__typename: 'Webhook',
121+
id: 4,
122+
...updateVariables.webhook,
123+
},
124+
]),
125+
{
126+
request: {
127+
query: DeleteRemoteWebhookDocument,
128+
variables: { id: 4 },
129+
},
130+
result: {
131+
data: {
132+
deleteWebhook: null,
133+
},
134+
},
135+
},
136+
webhooksMock([]),
137+
]),
138+
});
139+
140+
await waitFor(() => {
141+
expect(result.current.isLoading).toBe(false);
142+
});
143+
144+
await act(async () => {
145+
await result.current.saveWebhook(createVariables.webhook);
146+
});
147+
await act(async () => {
148+
await result.current.saveWebhook(updateVariables.webhook, 4);
149+
});
150+
await act(async () => {
151+
await result.current.removeWebhook(4);
152+
});
153+
154+
expect(result.current.error).toBeNull();
155+
});
156+
157+
it('tests saved and unsaved webhook settings', async () => {
158+
const webhook = {
159+
method: 'PUT' as const,
160+
url: 'https://example.com/test',
161+
};
162+
const { result } = renderHook(() => useNotifyCompRemoteWebhooks({ competitionId }), {
163+
wrapper: createWrapper([
164+
webhooksMock([]),
165+
{
166+
request: {
167+
query: TestRemoteWebhookDocument,
168+
variables: { id: 8 },
169+
},
170+
result: {
171+
data: {
172+
testWebhook: {
173+
__typename: 'WebhookResponse',
174+
body: 'ok',
175+
status: 200,
176+
statusText: 'OK',
177+
url: webhook.url,
178+
},
179+
},
180+
},
181+
},
182+
{
183+
request: {
184+
query: TestEditingRemoteWebhookDocument,
185+
variables: { competitionId, webhook },
186+
},
187+
result: {
188+
data: {
189+
testEditingWebhook: {
190+
__typename: 'WebhookResponse',
191+
body: 'failed',
192+
status: 500,
193+
statusText: 'Server Error',
194+
url: webhook.url,
195+
},
196+
},
197+
},
198+
},
199+
]),
200+
});
201+
202+
await waitFor(() => {
203+
expect(result.current.isLoading).toBe(false);
204+
});
205+
206+
await act(async () => {
207+
await result.current.testSavedWebhook(8);
208+
});
209+
210+
expect(result.current.testResult).toMatchObject({
211+
status: 200,
212+
statusText: 'OK',
213+
});
214+
215+
await act(async () => {
216+
await result.current.testWebhookSettings(webhook);
217+
});
218+
219+
expect(result.current.testResult).toMatchObject({
220+
status: 500,
221+
statusText: 'Server Error',
222+
});
223+
});
224+
});
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import { useMutation, useQuery } from '@apollo/client';
2+
import { useMemo, useState } from 'react';
3+
import {
4+
CreateRemoteWebhookDocument,
5+
DeleteRemoteWebhookDocument,
6+
NotifyCompWebhook,
7+
NotifyCompWebhookInput,
8+
NotifyCompWebhookResponse,
9+
RemoteWebhooksDocument,
10+
TestEditingRemoteWebhookDocument,
11+
TestRemoteWebhookDocument,
12+
UpdateRemoteWebhookDocument,
13+
} from '@/lib/notifyCompRemoteGraphql';
14+
15+
interface UseNotifyCompRemoteWebhooksParams {
16+
competitionId: string;
17+
enabled?: boolean;
18+
}
19+
20+
interface RemoteWebhooksQueryData {
21+
competition: {
22+
id: string;
23+
webhooks: NotifyCompWebhook[];
24+
} | null;
25+
}
26+
27+
export function useNotifyCompRemoteWebhooks({
28+
competitionId,
29+
enabled = true,
30+
}: UseNotifyCompRemoteWebhooksParams) {
31+
const [mutationError, setMutationError] = useState<string | null>(null);
32+
const [testResult, setTestResult] = useState<NotifyCompWebhookResponse | null>(null);
33+
34+
const webhooksQuery = useQuery<RemoteWebhooksQueryData>(RemoteWebhooksDocument, {
35+
variables: { competitionId },
36+
skip: !competitionId || !enabled,
37+
});
38+
39+
const refetchQueries = useMemo(
40+
() => [
41+
{
42+
query: RemoteWebhooksDocument,
43+
variables: { competitionId },
44+
},
45+
],
46+
[competitionId],
47+
);
48+
49+
const mutationOptions = {
50+
refetchQueries,
51+
};
52+
53+
const [createWebhook, createWebhookStatus] = useMutation<
54+
{ createWebhook: NotifyCompWebhook },
55+
{ competitionId: string; webhook: NotifyCompWebhookInput }
56+
>(CreateRemoteWebhookDocument, mutationOptions);
57+
const [updateWebhook, updateWebhookStatus] = useMutation<
58+
{ updateWebhook: NotifyCompWebhook },
59+
{ id: number; webhook: NotifyCompWebhookInput }
60+
>(UpdateRemoteWebhookDocument, mutationOptions);
61+
const [deleteWebhook, deleteWebhookStatus] = useMutation<
62+
{ deleteWebhook?: null },
63+
{ id: number }
64+
>(DeleteRemoteWebhookDocument, mutationOptions);
65+
const [testWebhook, testWebhookStatus] = useMutation<
66+
{ testWebhook: NotifyCompWebhookResponse | null },
67+
{ id: number }
68+
>(TestRemoteWebhookDocument);
69+
const [testEditingWebhook, testEditingWebhookStatus] = useMutation<
70+
{ testEditingWebhook: NotifyCompWebhookResponse | null },
71+
{ competitionId: string; webhook: NotifyCompWebhookInput }
72+
>(TestEditingRemoteWebhookDocument);
73+
74+
const runMutation = async (operation: () => Promise<unknown>) => {
75+
setMutationError(null);
76+
setTestResult(null);
77+
78+
try {
79+
await operation();
80+
} catch (err) {
81+
setMutationError(err instanceof Error ? err.message : 'Webhook operation failed.');
82+
throw err;
83+
}
84+
};
85+
86+
const saveWebhook = (webhook: NotifyCompWebhookInput, id?: number) =>
87+
runMutation(() =>
88+
id
89+
? updateWebhook({
90+
variables: { id, webhook },
91+
})
92+
: createWebhook({
93+
variables: { competitionId, webhook },
94+
}),
95+
);
96+
97+
const removeWebhook = (id: number) =>
98+
runMutation(() =>
99+
deleteWebhook({
100+
variables: { id },
101+
}),
102+
);
103+
104+
const testSavedWebhook = (id: number) =>
105+
runMutation(async () => {
106+
const result = await testWebhook({
107+
variables: { id },
108+
});
109+
setTestResult(result.data?.testWebhook || null);
110+
});
111+
112+
const testWebhookSettings = (webhook: NotifyCompWebhookInput) =>
113+
runMutation(async () => {
114+
const result = await testEditingWebhook({
115+
variables: { competitionId, webhook },
116+
});
117+
setTestResult(result.data?.testEditingWebhook || null);
118+
});
119+
120+
return {
121+
error: mutationError || webhooksQuery.error?.message || null,
122+
isLoading: webhooksQuery.loading,
123+
isSaving:
124+
createWebhookStatus.loading || updateWebhookStatus.loading || deleteWebhookStatus.loading,
125+
isTesting: testWebhookStatus.loading || testEditingWebhookStatus.loading,
126+
removeWebhook,
127+
saveWebhook,
128+
testResult,
129+
testSavedWebhook,
130+
testWebhookSettings,
131+
webhooks: webhooksQuery.data?.competition?.webhooks || [],
132+
};
133+
}

0 commit comments

Comments
 (0)