From 6ac0f243124aac998a7e89f1843edcc2e69ae511 Mon Sep 17 00:00:00 2001 From: Cailyn Sinclair Date: Sun, 12 Jul 2026 10:09:28 -0700 Subject: [PATCH 1/2] Preflight WCIF saves Validate the complete WCIF with the WCA check endpoint before PATCHing changed fields, and surface API error details to delegates. --- .../CompetitionLayout/CompetitionLayout.tsx | 2 +- .../_tests_/CompetitionLayout.test.tsx | 4 ++- src/lib/api/wcaAPI.test.ts | 29 ++++++++++++++++ src/lib/api/wcaAPI.ts | 33 ++++++++++++++++++- src/store/actions.test.ts | 26 ++++++++++++++- src/store/actions.ts | 5 +-- 6 files changed, 93 insertions(+), 6 deletions(-) diff --git a/src/layout/CompetitionLayout/CompetitionLayout.tsx b/src/layout/CompetitionLayout/CompetitionLayout.tsx index 35e71ac..09eb90f 100644 --- a/src/layout/CompetitionLayout/CompetitionLayout.tsx +++ b/src/layout/CompetitionLayout/CompetitionLayout.tsx @@ -83,7 +83,7 @@ export const CompetitionLayout = () => { dispatch( uploadCurrentWCIFChanges((e) => { if (e) { - enqueueSnackbar('Error saving changes', { variant: 'error' }); + enqueueSnackbar(`Error saving changes: ${e.message}`, { variant: 'error' }); } else { enqueueSnackbar('Saved!', { variant: 'success' }); } diff --git a/src/layout/CompetitionLayout/_tests_/CompetitionLayout.test.tsx b/src/layout/CompetitionLayout/_tests_/CompetitionLayout.test.tsx index c355741..10b9f4a 100644 --- a/src/layout/CompetitionLayout/_tests_/CompetitionLayout.test.tsx +++ b/src/layout/CompetitionLayout/_tests_/CompetitionLayout.test.tsx @@ -155,6 +155,8 @@ describe('CompetitionLayout', () => { const errorCallback = uploadCurrentWCIFChangesMock.mock.calls[1][0]; errorCallback(new Error('save failed')); - expect(enqueueSnackbarMock).toHaveBeenCalledWith('Error saving changes', { variant: 'error' }); + expect(enqueueSnackbarMock).toHaveBeenCalledWith('Error saving changes: save failed', { + variant: 'error', + }); }); }); diff --git a/src/lib/api/wcaAPI.test.ts b/src/lib/api/wcaAPI.test.ts index 847c12a..73db22f 100644 --- a/src/lib/api/wcaAPI.test.ts +++ b/src/lib/api/wcaAPI.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi, afterEach } from 'vitest'; import { + checkWcif, getMe, getPastManageableCompetitions, getUpcomingManageableCompetitions, @@ -52,6 +53,34 @@ describe('wcaAPI', () => { await expect(wcaApiFetch('/me')).rejects.toThrow('Something went wrong: Status code 418'); }); + it('uses an API error response when one is available', async () => { + mockFetch({ + ok: false, + status: 400, + statusText: 'Bad Request', + json: vi.fn().mockResolvedValue({ error: 'WCIF formatVersion is required' }), + }); + + await expect(wcaApiFetch('/me')).rejects.toThrow('WCIF formatVersion is required'); + }); + + it('checks a complete WCIF without parsing the empty success response', async () => { + const json = vi.fn(); + const wcif = { id: 'Comp', formatVersion: '1.1' } as any; + mockFetch({ json }); + + await checkWcif(wcif); + + expect(globalThis.fetch).toHaveBeenCalledWith( + 'https://wca.test/api/v0/competitions/wcif/check', + expect.objectContaining({ + method: 'PUT', + body: JSON.stringify(wcif), + }) + ); + expect(json).not.toHaveBeenCalled(); + }); + it('builds upcoming and past competition queries', async () => { vi.spyOn(Date, 'now').mockReturnValue(0); mockFetch({ json: vi.fn().mockResolvedValue([]) }); diff --git a/src/lib/api/wcaAPI.ts b/src/lib/api/wcaAPI.ts index 8313d6c..7a29196 100644 --- a/src/lib/api/wcaAPI.ts +++ b/src/lib/api/wcaAPI.ts @@ -56,6 +56,16 @@ export const patchWcif = ( body: JSON.stringify(wcif), }); +export const checkWcif = (wcif: Competition): Promise => + wcaApiFetch( + '/competitions/wcif/check', + { + method: 'PUT', + body: JSON.stringify(wcif), + }, + false + ); + export const saveWcifChanges = ( previousWcif: Competition, newWcif: Competition @@ -82,7 +92,8 @@ export const getUser = (userId: number): Promise<{ user: WcaUser }> => export const wcaApiFetch = async ( path: string, - fetchOptions: RequestInit = {} + fetchOptions: RequestInit = {}, + parseJsonResponse = true ): Promise => { const baseApiUrl = `${WCA_ORIGIN}/api/v0`; @@ -97,6 +108,9 @@ export const wcaApiFetch = async ( ); if (!res.ok) { + const error = await errorFromResponse(res); + if (error) throw new Error(error); + if (res.statusText) { throw new Error(`${res.status}: ${res.statusText}`); } else { @@ -104,5 +118,22 @@ export const wcaApiFetch = async ( } } + if (!parseJsonResponse) return undefined as T; + return await res.json(); }; + +const errorFromResponse = async (res: Response): Promise => { + try { + const body: unknown = await res.json(); + if (Array.isArray(body)) return body.map(String).join('\n'); + + if (body && typeof body === 'object' && 'error' in body) { + const error = body.error; + if (Array.isArray(error)) return error.map(String).join('\n'); + if (typeof error === 'string') return error; + } + } catch { + // Fall back to the HTTP status when the API does not return JSON. + } +}; diff --git a/src/store/actions.test.ts b/src/store/actions.test.ts index 32b006a..2bdf9a7 100644 --- a/src/store/actions.test.ts +++ b/src/store/actions.test.ts @@ -26,7 +26,7 @@ import { import type { Assignment, Competition } from '@wca/helpers'; import type { Extension } from '@wca/helpers/lib/models/extension'; import { describe, expect, it, vi } from 'vitest'; -import { getUpcomingManageableCompetitions, getWcif, patchWcif } from '../lib/api'; +import { checkWcif, getUpcomingManageableCompetitions, getWcif, patchWcif } from '../lib/api'; import { sortWcifEvents } from '../lib/domain/events'; import { validateWcif } from '../lib/wcif/validation'; import type { AppState } from './initialState'; @@ -41,6 +41,7 @@ import { vi.mock('../lib/api', () => ({ getUpcomingManageableCompetitions: vi.fn(), getWcif: vi.fn(), + checkWcif: vi.fn(), patchWcif: vi.fn(), })); @@ -54,6 +55,7 @@ vi.mock('../lib/wcif/validation', () => ({ const getUpcomingManageableCompetitionsMock = vi.mocked(getUpcomingManageableCompetitions); const getWcifMock = vi.mocked(getWcif); +const checkWcifMock = vi.mocked(checkWcif); const patchWcifMock = vi.mocked(patchWcif); const sortWcifEventsMock = vi.mocked(sortWcifEvents); const validateWcifMock = vi.mocked(validateWcif); @@ -311,6 +313,7 @@ describe('store actions', () => { wcif, changedKeys: new Set(['events']), }) as unknown as AppState; + checkWcifMock.mockResolvedValueOnce(undefined); patchWcifMock.mockResolvedValueOnce(wcif); uploadCurrentWCIFChanges(cb)(dispatch, getState); @@ -320,6 +323,7 @@ describe('store actions', () => { type: ActionType.UPLOADING_WCIF, uploading: true, }); + expect(checkWcifMock).toHaveBeenCalledWith(wcif); expect(patchWcifMock).toHaveBeenCalledWith('Comp1', { formatVersion: wcif.formatVersion, events: wcif.events, @@ -343,6 +347,7 @@ describe('store actions', () => { changedKeys: new Set(['events']), }) as unknown as AppState; const error = new Error('Upload failed'); + checkWcifMock.mockResolvedValueOnce(undefined); patchWcifMock.mockRejectedValueOnce(error); const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); @@ -360,4 +365,23 @@ describe('store actions', () => { expect(cb).toHaveBeenCalledWith(error); consoleError.mockRestore(); }); + + it('does not patch when the WCIF schema check fails', async () => { + vi.clearAllMocks(); + const dispatch = vi.fn(); + const cb = vi.fn(); + const wcif = { ...buildWcif([], []), id: 'Comp1' }; + const error = new Error('WCIF formatVersion is required'); + const getState = () => + ({ wcif, changedKeys: new Set(['events']) }) as unknown as AppState; + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); + checkWcifMock.mockRejectedValueOnce(error); + + uploadCurrentWCIFChanges(cb)(dispatch, getState); + await flushPromises(); + + expect(patchWcifMock).not.toHaveBeenCalled(); + expect(cb).toHaveBeenCalledWith(error); + consoleError.mockRestore(); + }); }); diff --git a/src/store/actions.ts b/src/store/actions.ts index bcedf93..fef6ee0 100644 --- a/src/store/actions.ts +++ b/src/store/actions.ts @@ -1,4 +1,4 @@ -import { getUpcomingManageableCompetitions, getWcif, patchWcif } from '../lib/api'; +import { checkWcif, getUpcomingManageableCompetitions, getWcif, patchWcif } from '../lib/api'; import { sortWcifEvents } from '../lib/domain/events'; import { type BulkInProgressAssignments } from '../lib/types'; import { validateWcif, type ValidationError } from '../lib/wcif/validation'; @@ -163,7 +163,8 @@ export const uploadCurrentWCIFChanges = const changes = pick(wcif, keysForPatch); dispatch(updateUploading(true)); - patchWcif(competitionId, changes) + checkWcif(wcif) + .then(() => patchWcif(competitionId, changes)) .then(() => { dispatch(updateUploading(false)); cb(); From da2947c276e76f3ce3d3b0d60fb249cba54f02b6 Mon Sep 17 00:00:00 2001 From: Cailyn Sinclair Date: Mon, 10 Aug 2026 07:45:57 -0700 Subject: [PATCH 2/2] Use WCIF v2 read endpoint Fetch version 2 WCIF data while retaining the existing PATCH endpoint and preflight validation flow. --- e2e/mocks/wcaApi.ts | 7 ++++++- src/lib/api/wcaAPI.test.ts | 32 +++++++++++++++++++++++++++++++- src/lib/api/wcaAPI.ts | 8 ++++++-- 3 files changed, 43 insertions(+), 4 deletions(-) diff --git a/e2e/mocks/wcaApi.ts b/e2e/mocks/wcaApi.ts index 953cf35..ab2c8b8 100644 --- a/e2e/mocks/wcaApi.ts +++ b/e2e/mocks/wcaApi.ts @@ -100,7 +100,7 @@ export const registerWcaApiRoutes = async ( return; } - const wcifMatch = apiPath.match(/^\/competitions\/([^/]+)\/wcif$/); + const wcifMatch = apiPath.match(/^\/competitions\/([^/]+)\/wcif(?:\/version\/2)?$/); if (wcifMatch && method === 'GET') { const competitionId = wcifMatch[1]; const wcif = state.wcifById[competitionId] ?? state.wcif; @@ -122,6 +122,11 @@ export const registerWcaApiRoutes = async ( return; } + if (apiPath === '/competitions/wcif/check' && method === 'PUT') { + await route.fulfill({ status: 204, headers: corsHeaders, body: '' }); + return; + } + if (apiPath === '/persons' && method === 'GET') { await route.fulfill(jsonResponse(fixtures.personsSearch)); return; diff --git a/src/lib/api/wcaAPI.test.ts b/src/lib/api/wcaAPI.test.ts index 73db22f..03d656d 100644 --- a/src/lib/api/wcaAPI.test.ts +++ b/src/lib/api/wcaAPI.test.ts @@ -4,6 +4,8 @@ import { getMe, getPastManageableCompetitions, getUpcomingManageableCompetitions, + getWcif, + patchWcif, saveWcifChanges, wcaApiFetch, } from './wcaAPI'; @@ -101,6 +103,7 @@ describe('wcaAPI', () => { mockFetch({ json: vi.fn().mockResolvedValue({ id: 'Comp', name: 'New' }) }); const previousWcif = { id: 'Comp', + formatVersion: '2.0', name: 'Old', schedule: { startDate: '2024-01-01', numberOfDays: 1, venues: [] }, events: [], @@ -115,7 +118,7 @@ describe('wcaAPI', () => { 'https://wca.test/api/v0/competitions/Comp/wcif', expect.objectContaining({ method: 'PATCH', - body: JSON.stringify({ name: 'New' }), + body: JSON.stringify({ formatVersion: '2.0', name: 'New' }), }) ); }); @@ -138,4 +141,31 @@ describe('wcaAPI', () => { expect.objectContaining({ method: 'PATCH' }) ); }); + + it('fetches WCIF from the version 2 endpoint', async () => { + mockFetch({ json: vi.fn().mockResolvedValue({ id: 'Comp' }) }); + + await getWcif('Comp'); + + expect(globalThis.fetch).toHaveBeenCalledWith( + 'https://wca.test/api/v0/competitions/Comp/wcif/version/2', + expect.objectContaining({ + headers: expect.any(Headers), + }) + ); + }); + + it('patches WCIF to the unchanged update endpoint', async () => { + mockFetch({ json: vi.fn().mockResolvedValue({ id: 'Comp' }) }); + + await patchWcif('Comp', { formatVersion: '2.0', name: 'Updated' } as any); + + expect(globalThis.fetch).toHaveBeenCalledWith( + 'https://wca.test/api/v0/competitions/Comp/wcif', + expect.objectContaining({ + method: 'PATCH', + body: JSON.stringify({ formatVersion: '2.0', name: 'Updated' }), + }) + ); + }); }); diff --git a/src/lib/api/wcaAPI.ts b/src/lib/api/wcaAPI.ts index 7a29196..8c19014 100644 --- a/src/lib/api/wcaAPI.ts +++ b/src/lib/api/wcaAPI.ts @@ -10,6 +10,10 @@ import { type Competition } from '@wca/helpers'; import { pick } from 'lodash'; const wcaAccessToken = (): string | null => getLocalStorage('accessToken'); +const WCIF_VERSION = '2'; +const wcifPath = (competitionId: string) => `/competitions/${competitionId}/wcif`; +const versionedWcifPath = (competitionId: string) => + `${wcifPath(competitionId)}/version/${WCIF_VERSION}`; export const getMe = (): Promise<{ me: WcaUser }> => { return wcaApiFetch(`/me`); @@ -45,13 +49,13 @@ export const getPastManageableCompetitions = (): Promise => - wcaApiFetch(`/competitions/${competitionId}/wcif`); + wcaApiFetch(versionedWcifPath(competitionId)); export const patchWcif = ( competitionId: string, wcif: Partial ): Promise => - wcaApiFetch(`/competitions/${competitionId}/wcif`, { + wcaApiFetch(wcifPath(competitionId), { method: 'PATCH', body: JSON.stringify(wcif), });