Skip to content

Commit 6ead1a7

Browse files
authored
Merge pull request #75 from coder13/beta
Deploy beta to production
2 parents 4fce6fb + 2830e96 commit 6ead1a7

25 files changed

Lines changed: 2251 additions & 61 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 />} />

src/containers/CompetitionRound/CompetitionRound.test.tsx

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,10 @@ jest.mock('react-i18next', () => ({
5757
return `Round ${options?.roundNumber}`;
5858
}
5959

60+
if (key === 'common.activityCodeToName.group') {
61+
return `Group ${options?.groupNumber}`;
62+
}
63+
6064
return key;
6165
},
6266
}),
@@ -89,7 +93,15 @@ const linkedRoundsCompetition = {
8993
cutoff: null,
9094
timeLimit: null,
9195
advancementCondition: null,
92-
results: [],
96+
results: [
97+
{
98+
personId: 1,
99+
ranking: 1,
100+
attempts: [],
101+
best: 1000,
102+
average: 1200,
103+
},
104+
],
93105
},
94106
{
95107
id: '333-r3',
@@ -195,9 +207,29 @@ describe('CompetitionRoundContainer', () => {
195207
it('links to results for the selected round', () => {
196208
renderRound('333-r2');
197209

198-
expect(screen.getByText('See Results')).toHaveAttribute(
199-
'href',
200-
'/competitions/TestComp2026/results/333-r2',
210+
const resultsLink = screen.getByText('See Results');
211+
212+
expect(resultsLink).toHaveAttribute('href', '/competitions/TestComp2026/results/333-r2');
213+
expect(screen.getByText('Group 1').compareDocumentPosition(resultsLink)).toBe(
214+
Node.DOCUMENT_POSITION_FOLLOWING,
201215
);
202216
});
217+
218+
it('uses shared button spacing for the back link', () => {
219+
renderRound('333-r2');
220+
221+
expect(screen.getByText('Back To Events')).toHaveClass('btn', 'btn-block');
222+
});
223+
224+
it('shows when the selected round has results', () => {
225+
renderRound('333-r2');
226+
227+
expect(screen.getByText('See Results')).toHaveClass('btn-green');
228+
});
229+
230+
it('shows a neutral results link when the selected round has no results yet', () => {
231+
renderRound('333-r3');
232+
233+
expect(screen.getByText('See Results')).toHaveClass('btn-light');
234+
});
203235
});

src/containers/CompetitionRound/CompetitionRound.tsx

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ export function CompetitionRoundContainer({
6060
const rounds = roundActivies.filter((ra) => toRoundAttemptId(ra.activityCode) === roundId);
6161
const groups = rounds.flatMap((r) => r.childActivities);
6262
const uniqueGroupCodes = [...new Set(groups.map((g) => g.activityCode))];
63+
const hasResults = (round?.results.length ?? 0) > 0;
6364

6465
return (
6566
<Container>
@@ -79,14 +80,6 @@ export function CompetitionRoundContainer({
7980
</p>
8081
)}
8182
{round && <CutoffTimeLimitPanel round={round} />}
82-
{round && (
83-
<LinkButton
84-
to={`/competitions/${competitionId}/results/${roundId}`}
85-
title={t('competition.results.seeResults')}
86-
variant="light"
87-
LinkComponent={LinkComponent}
88-
/>
89-
)}
9083
</div>
9184
</div>
9285
<ul className="flex flex-col space-y-2 p-2">
@@ -111,12 +104,22 @@ export function CompetitionRoundContainer({
111104
);
112105
})}
113106
</ul>
107+
{round && (
108+
<div className="space-y-2 p-2">
109+
<LinkButton
110+
to={`/competitions/${competitionId}/results/${roundId}`}
111+
title={t('competition.results.seeResults')}
112+
variant={hasResults ? 'green' : 'light'}
113+
LinkComponent={LinkComponent}
114+
/>
115+
</div>
116+
)}
114117
<div className="p-2">
115-
<LinkComponent
118+
<LinkButton
116119
to={`/competitions/${competitionId}/events/`}
117-
className="my-1 flex w-full flex-row rounded-md border border-primary bg-primary p-2 px-1 hover-transition hover:bg-primary-strong group dark:text-gray-100">
118-
{t('competition.groups.backToEvents')}
119-
</LinkComponent>
120+
title={t('competition.groups.backToEvents')}
121+
LinkComponent={LinkComponent}
122+
/>
120123
</div>
121124
</Container>
122125
);

src/containers/MyCompetitions/MyCompetitions.query.ts

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -25,12 +25,18 @@ export const useMyCompetitionsQuery = (userId?: number, options: { enabled?: boo
2525
return undefined;
2626
}
2727

28-
const upcoming_competitions = JSON.parse(
29-
getLocalStorage('my.upcoming_competitions') || '[]',
30-
) as ApiCompetition[];
31-
const ongoing_competitions = JSON.parse(
32-
getLocalStorage('my.ongoing_competitions') || '[]',
33-
) as ApiCompetition[];
28+
const rawUpcomingCompetitions = getLocalStorage('my.upcoming_competitions');
29+
const rawOngoingCompetitions = getLocalStorage('my.ongoing_competitions');
30+
if (!rawUpcomingCompetitions && !rawOngoingCompetitions) {
31+
return undefined;
32+
}
33+
34+
const upcoming_competitions = JSON.parse(rawUpcomingCompetitions || '[]') as ApiCompetition[];
35+
const ongoing_competitions = JSON.parse(rawOngoingCompetitions || '[]') as ApiCompetition[];
36+
37+
if (!upcoming_competitions.length && !ongoing_competitions.length) {
38+
return undefined;
39+
}
3440

3541
return { user: user, upcoming_competitions, ongoing_competitions };
3642
},
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+
});

0 commit comments

Comments
 (0)