From bc799df73e0fefb4a57e398b6bccfa6518df1672 Mon Sep 17 00:00:00 2001 From: Collin Beczak Date: Thu, 16 Jul 2026 17:42:13 -0500 Subject: [PATCH 1/6] add unit tests --- src/api/challenge/comments.test.tsx | 120 ++++ src/api/challenge/comments.ts | 2 +- src/api/challenge/explore.test.tsx | 225 +++++++ src/api/challenge/explore.ts | 2 +- src/api/challenge/favorites.test.tsx | 79 +++ src/api/challenge/favorites.ts | 2 +- src/api/challenge/index.test.ts | 67 ++ src/api/challenge/likes.test.tsx | 118 ++++ src/api/challenge/likes.ts | 2 +- src/api/challenge/single.test.tsx | 609 ++++++++++++++++++ src/api/challenge/single.ts | 2 +- src/api/client.ts | 62 ++ src/api/index.test.ts | 145 +++++ src/api/index.ts | 68 +- src/api/osm.test.ts | 345 ++++++++++ src/api/project.test.tsx | 382 +++++++++++ src/api/project.ts | 2 +- src/api/search.test.tsx | 101 +++ src/api/search.ts | 2 +- src/api/service/index.test.tsx | 44 ++ src/api/service/info.test.tsx | 51 ++ src/api/service/info.ts | 2 +- src/api/task/bulk.test.tsx | 132 ++++ src/api/task/bulk.ts | 2 +- src/api/task/comments.test.tsx | 159 +++++ src/api/task/comments.ts | 2 +- src/api/task/index.test.tsx | 115 ++++ src/api/task/multiple.test.tsx | 242 +++++++ src/api/task/multiple.ts | 2 +- src/api/task/single.test.tsx | 431 +++++++++++++ src/api/task/single.ts | 2 +- src/api/task/tags.test.tsx | 106 +++ src/api/task/tags.ts | 2 +- src/api/taskBundle/index.test.tsx | 69 ++ src/api/taskBundle/queries.test.tsx | 232 +++++++ src/api/taskBundle/queries.ts | 2 +- src/api/team/index.test.tsx | 248 +++++++ src/api/team/index.ts | 2 +- src/api/user/admin.test.tsx | 76 +++ src/api/user/admin.ts | 2 +- src/api/user/auth.test.tsx | 85 +++ src/api/user/auth.ts | 2 +- src/api/user/index.test.ts | 42 ++ src/api/user/notifications.test.tsx | 132 ++++ src/api/user/notifications.ts | 2 +- src/api/user/profile.test.tsx | 226 +++++++ src/api/user/profile.ts | 2 +- src/api/user/search.test.tsx | 77 +++ src/api/user/search.ts | 2 +- .../TaskMarkers/useChallengeTypes.test.tsx | 101 +++ .../useScrollIndicator.test.tsx | 175 +++++ .../ManageChallengeNew/ChallengeForm.test.tsx | 194 ++++++ .../ManageProjectNew/ProjectForm.test.tsx | 85 +++ .../ManageTaskEdit/TaskForm.test.tsx | 85 +++ .../UserSettingsForm/formSchema.test.ts | 129 ++++ .../TaskMap/useAllMarkersMap.test.tsx | 84 +++ .../TaskMap/useLassoBundleSync.test.tsx | 186 ++++++ .../TaskMap/useLassoEvents.test.tsx | 404 ++++++++++++ .../TaskMap/useMapControlButtons.test.tsx | 222 +++++++ .../TaskMap/useMapNavigation.test.tsx | 162 +++++ .../TaskMap/useMarkerVisibility.test.tsx | 97 +++ .../TaskMap/useStyledClusteredData.test.tsx | 144 +++++ .../TaskMap/useTaskMapShortcuts.test.tsx | 209 ++++++ .../Pages/TeamsPage/teamSchema.test.ts | 91 +++ .../useColumnConfig.test.tsx | 187 ++++++ .../useActionSummary.test.tsx | 110 ++++ src/hooks/useChallengeProgress.test.tsx | 188 ++++++ src/hooks/useCopyToClipboard.test.tsx | 90 +++ src/hooks/useNotificationFilters.test.tsx | 295 +++++++++ src/hooks/useNotificationThreads.test.tsx | 101 +++ src/hooks/useRowSelection.test.tsx | 76 +++ src/hooks/useShareSupport.test.tsx | 37 ++ src/hooks/useWebSocketEvents.test.tsx | 376 +++++++++++ .../challenge/[$challengeId]/index.test.ts | 38 ++ src/routes/_app/index.test.ts | 96 +++ src/routes/_app/manage/challenge/new.test.ts | 30 + src/routes/_app/notifications.test.ts | 67 ++ src/routes/_app/tasks/[$taskId]/index.test.ts | 32 + src/test/queryClient.tsx | 26 + vite.config.ts | 6 +- 80 files changed, 8869 insertions(+), 82 deletions(-) create mode 100644 src/api/challenge/comments.test.tsx create mode 100644 src/api/challenge/explore.test.tsx create mode 100644 src/api/challenge/favorites.test.tsx create mode 100644 src/api/challenge/index.test.ts create mode 100644 src/api/challenge/likes.test.tsx create mode 100644 src/api/challenge/single.test.tsx create mode 100644 src/api/client.ts create mode 100644 src/api/index.test.ts create mode 100644 src/api/osm.test.ts create mode 100644 src/api/project.test.tsx create mode 100644 src/api/search.test.tsx create mode 100644 src/api/service/index.test.tsx create mode 100644 src/api/service/info.test.tsx create mode 100644 src/api/task/bulk.test.tsx create mode 100644 src/api/task/comments.test.tsx create mode 100644 src/api/task/index.test.tsx create mode 100644 src/api/task/multiple.test.tsx create mode 100644 src/api/task/single.test.tsx create mode 100644 src/api/task/tags.test.tsx create mode 100644 src/api/taskBundle/index.test.tsx create mode 100644 src/api/taskBundle/queries.test.tsx create mode 100644 src/api/team/index.test.tsx create mode 100644 src/api/user/admin.test.tsx create mode 100644 src/api/user/auth.test.tsx create mode 100644 src/api/user/index.test.ts create mode 100644 src/api/user/notifications.test.tsx create mode 100644 src/api/user/profile.test.tsx create mode 100644 src/api/user/search.test.tsx create mode 100644 src/components/Map/TaskMarkers/useChallengeTypes.test.tsx create mode 100644 src/components/Pages/BrowsedChallengePage/ChallengePanel/useScrollIndicator.test.tsx create mode 100644 src/components/Pages/ManagementPages/ManageChallengeNew/ChallengeForm.test.tsx create mode 100644 src/components/Pages/ManagementPages/ManageProjectNew/ProjectForm.test.tsx create mode 100644 src/components/Pages/ManagementPages/ManageTaskEdit/TaskForm.test.tsx create mode 100644 src/components/Pages/SettingsPage/UserSettingsForm/formSchema.test.ts create mode 100644 src/components/Pages/TaskEditPage/TaskMap/useAllMarkersMap.test.tsx create mode 100644 src/components/Pages/TaskEditPage/TaskMap/useLassoBundleSync.test.tsx create mode 100644 src/components/Pages/TaskEditPage/TaskMap/useLassoEvents.test.tsx create mode 100644 src/components/Pages/TaskEditPage/TaskMap/useMapControlButtons.test.tsx create mode 100644 src/components/Pages/TaskEditPage/TaskMap/useMapNavigation.test.tsx create mode 100644 src/components/Pages/TaskEditPage/TaskMap/useMarkerVisibility.test.tsx create mode 100644 src/components/Pages/TaskEditPage/TaskMap/useStyledClusteredData.test.tsx create mode 100644 src/components/Pages/TaskEditPage/TaskMap/useTaskMapShortcuts.test.tsx create mode 100644 src/components/Pages/TeamsPage/teamSchema.test.ts create mode 100644 src/components/shared/ConfigureColumnsDialog/useColumnConfig.test.tsx create mode 100644 src/components/shared/StatusBreakdownBar/useActionSummary.test.tsx create mode 100644 src/hooks/useChallengeProgress.test.tsx create mode 100644 src/hooks/useCopyToClipboard.test.tsx create mode 100644 src/hooks/useNotificationFilters.test.tsx create mode 100644 src/hooks/useNotificationThreads.test.tsx create mode 100644 src/hooks/useRowSelection.test.tsx create mode 100644 src/hooks/useShareSupport.test.tsx create mode 100644 src/hooks/useWebSocketEvents.test.tsx create mode 100644 src/routes/_app/challenge/[$challengeId]/index.test.ts create mode 100644 src/routes/_app/index.test.ts create mode 100644 src/routes/_app/manage/challenge/new.test.ts create mode 100644 src/routes/_app/notifications.test.ts create mode 100644 src/routes/_app/tasks/[$taskId]/index.test.ts create mode 100644 src/test/queryClient.tsx diff --git a/src/api/challenge/comments.test.tsx b/src/api/challenge/comments.test.tsx new file mode 100644 index 000000000..8fac462e8 --- /dev/null +++ b/src/api/challenge/comments.test.tsx @@ -0,0 +1,120 @@ +import { waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { renderHookWithClient } from '@/test/queryClient' + +const { apiRequestMock } = vi.hoisted(() => ({ + apiRequestMock: { + get: vi.fn(), + post: vi.fn(), + put: vi.fn(), + delete: vi.fn(), + }, +})) + +vi.mock('@/api/client', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, apiRequest: apiRequestMock } +}) + +import { challengeComments } from './comments' + +describe('challengeComments', () => { + beforeEach(() => { + apiRequestMock.get.mockReset() + apiRequestMock.post.mockReset() + }) + + it('searchChallengeComments GETs the search endpoint with q and limit', async () => { + apiRequestMock.get.mockReturnValue({ json: () => Promise.resolve([{ id: 1 }]) }) + + const { result } = renderHookWithClient(() => + challengeComments.searchChallengeComments({ q: 'foo', limit: 5 }) + ) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(apiRequestMock.get).toHaveBeenCalledWith('api/v2/challengeComments/search', { + searchParams: { q: 'foo', limit: 5 }, + }) + expect(result.current.data).toEqual([{ id: 1 }]) + }) + + it('searchChallengeComments defaults limit to 10 and is enabled by default', () => { + apiRequestMock.get.mockReturnValue({ json: () => Promise.resolve([]) }) + + renderHookWithClient(() => challengeComments.searchChallengeComments({ q: 'bar' })) + + expect(apiRequestMock.get).toHaveBeenCalledWith('api/v2/challengeComments/search', { + searchParams: { q: 'bar', limit: 10 }, + }) + }) + + it('searchChallengeComments does not fetch when enabled is false', () => { + const { result } = renderHookWithClient(() => + challengeComments.searchChallengeComments({ q: 'bar', enabled: false }) + ) + + expect(result.current.fetchStatus).toBe('idle') + expect(apiRequestMock.get).not.toHaveBeenCalled() + }) + + it('getChallengeComments GETs the challenge comments endpoint keyed by challengeId', async () => { + apiRequestMock.get.mockReturnValue({ json: () => Promise.resolve([{ id: 2 }]) }) + + const { result } = renderHookWithClient(() => challengeComments.getChallengeComments(42)) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(apiRequestMock.get).toHaveBeenCalledWith('api/v2/challenge/42/challengeComments') + expect(result.current.data).toEqual([{ id: 2 }]) + }) + + it('getChallengeComments is disabled for a falsy challengeId', () => { + const { result } = renderHookWithClient(() => challengeComments.getChallengeComments(0)) + + expect(result.current.fetchStatus).toBe('idle') + expect(apiRequestMock.get).not.toHaveBeenCalled() + }) + + it('getTaskComments GETs the task comments endpoint and defaults to an empty object', async () => { + apiRequestMock.get.mockReturnValue({ json: () => Promise.resolve(null) }) + + const { result } = renderHookWithClient(() => challengeComments.getTaskComments(7)) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(apiRequestMock.get).toHaveBeenCalledWith('api/v2/challenge/7/comments') + expect(result.current.data).toEqual({}) + }) + + it('getTaskComments returns the response as-is when present', async () => { + apiRequestMock.get.mockReturnValue({ json: () => Promise.resolve({ '1': [{ id: 9 }] }) }) + + const { result } = renderHookWithClient(() => challengeComments.getTaskComments(7)) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(result.current.data).toEqual({ '1': [{ id: 9 }] }) + }) + + it('useAddChallengeComment POSTs the comment and invalidates the comment caches', async () => { + apiRequestMock.post.mockReturnValue({ + json: () => Promise.resolve({ id: 3, comment: 'hi', created: 1 }), + }) + + const { result, queryClient } = renderHookWithClient(() => + challengeComments.useAddChallengeComment() + ) + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries') + + result.current.mutate({ challengeId: 42, comment: 'hi' }) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(apiRequestMock.post).toHaveBeenCalledWith('api/v2/challenge/42/comment', { + json: { comment: 'hi' }, + }) + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['challenge', 'comments', 42] }) + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['challenge', 'taskComments', 42] }) + }) +}) diff --git a/src/api/challenge/comments.ts b/src/api/challenge/comments.ts index 427e8f052..569cbfe33 100644 --- a/src/api/challenge/comments.ts +++ b/src/api/challenge/comments.ts @@ -1,6 +1,6 @@ import { queryOptions, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import type { Comment } from '@/types/Comment' -import { apiRequest } from '../' +import { apiRequest } from '../client' export interface ChallengeCommentResponse { id: number diff --git a/src/api/challenge/explore.test.tsx b/src/api/challenge/explore.test.tsx new file mode 100644 index 000000000..1e0bc85b9 --- /dev/null +++ b/src/api/challenge/explore.test.tsx @@ -0,0 +1,225 @@ +import { waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { renderHookWithClient } from '@/test/queryClient' +import type { + ChallengeGetResponse, + ExploreChallengesParams, + FeaturedChallengesParams, + PreferredChallengesParams, +} from '@/types/Challenge' + +const { apiRequestMock } = vi.hoisted(() => ({ + apiRequestMock: { + get: vi.fn(), + post: vi.fn(), + put: vi.fn(), + delete: vi.fn(), + }, +})) + +vi.mock('@/api/client', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, apiRequest: apiRequestMock } +}) + +import { challengeExplore } from './explore' + +function makeChallenge(id: number): ChallengeGetResponse { + return { id } as unknown as ChallengeGetResponse +} + +function makeExploreParams(props: Record): ExploreChallengesParams { + return props as unknown as ExploreChallengesParams +} + +function makeFeaturedParams(props: Record): FeaturedChallengesParams { + return props as unknown as FeaturedChallengesParams +} + +function makePreferredParams(props: Record): PreferredChallengesParams { + return props as unknown as PreferredChallengesParams +} + +describe('challengeExplore', () => { + beforeEach(() => { + apiRequestMock.get.mockReset() + }) + + it('preferredChallenges GETs the preferred endpoint with the given params', async () => { + apiRequestMock.get.mockReturnValue({ json: () => Promise.resolve([{ id: 1 }]) }) + + const { result } = renderHookWithClient(() => + challengeExplore.preferredChallenges(makePreferredParams({ limit: 5 })) + ) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(apiRequestMock.get).toHaveBeenCalledWith('api/v2/challenges/preferred', { + searchParams: { limit: 5 }, + }) + }) + + it('featuredChallenges GETs the featured endpoint and caches each challenge by id', async () => { + apiRequestMock.get.mockReturnValue({ + json: () => Promise.resolve([makeChallenge(1), makeChallenge(2)]), + }) + + const { result, queryClient } = renderHookWithClient(() => + challengeExplore.featuredChallenges(makeFeaturedParams({})) + ) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(apiRequestMock.get).toHaveBeenCalledWith('api/v2/challenges/featured', { + searchParams: {}, + }) + expect(queryClient.getQueryData(['challenge', 1])).toEqual(makeChallenge(1)) + expect(queryClient.getQueryData(['challenge', 2])).toEqual(makeChallenge(2)) + }) + + it('exploreChallenges converts params to search params and caches each challenge', async () => { + apiRequestMock.get.mockReturnValue({ + json: () => Promise.resolve([makeChallenge(3)]), + }) + + const { result, queryClient } = renderHookWithClient(() => + challengeExplore.exploreChallenges(makeExploreParams({ limit: 10, tags: ['a', 'b'] })) + ) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(apiRequestMock.get).toHaveBeenCalledWith('api/v2/challenges/exploreChallenges', { + searchParams: { limit: 10, tags: 'a,b' }, + }) + expect(queryClient.getQueryData(['challenge', 3])).toEqual(makeChallenge(3)) + }) + + it('exploreChallenges passes undefined searchParams when params is falsy', async () => { + apiRequestMock.get.mockReturnValue({ json: () => Promise.resolve([]) }) + + renderHookWithClient(() => + challengeExplore.exploreChallenges(undefined as unknown as ExploreChallengesParams) + ) + + expect(apiRequestMock.get).toHaveBeenCalledWith('api/v2/challenges/exploreChallenges', { + searchParams: undefined, + }) + }) + + it('exploreChallengesInfinite requests offset 0 on the first page and caches challenges', async () => { + apiRequestMock.get.mockReturnValue({ + json: () => Promise.resolve([makeChallenge(4)]), + }) + + const { result, queryClient } = renderHookWithClient(() => + challengeExplore.exploreChallengesInfinite(makeExploreParams({ limit: 2 })) + ) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(apiRequestMock.get).toHaveBeenCalledWith('api/v2/challenges/exploreChallenges', { + searchParams: { limit: 2, offset: 0 }, + }) + expect(queryClient.getQueryData(['challenge', 4])).toEqual(makeChallenge(4)) + }) + + it('exploreChallengesInfinite getNextPageParam returns undefined once a page is short of the limit', async () => { + apiRequestMock.get.mockReturnValue({ + json: () => Promise.resolve([makeChallenge(5)]), + }) + + const { result } = renderHookWithClient(() => + challengeExplore.exploreChallengesInfinite(makeExploreParams({ limit: 10 })) + ) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(result.current.hasNextPage).toBe(false) + }) + + it('exploreChallengesInfinite getNextPageParam returns the next offset when a page is full', async () => { + apiRequestMock.get.mockReturnValue({ + json: () => Promise.resolve([makeChallenge(6)]), + }) + + const { result } = renderHookWithClient(() => + challengeExplore.exploreChallengesInfinite(makeExploreParams({ limit: 1 })) + ) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(result.current.hasNextPage).toBe(true) + }) + + it('getChallengesListingOptions builds the listing query with defaults', () => { + const options = challengeExplore.getChallengesListingOptions([1, 2]) + + expect(options.queryKey).toEqual([ + 'challenge', + 'listing', + [1, 2], + { limit: -1, onlyEnabled: false }, + ]) + }) + + it('getChallengesListingOptions.queryFn GETs the listing endpoint with joined project ids', async () => { + apiRequestMock.get.mockReturnValue({ json: () => Promise.resolve({ challenges: [] }) }) + + const options = challengeExplore.getChallengesListingOptions([1, 2], { + limit: 25, + onlyEnabled: true, + }) + + expect(options.queryKey).toEqual([ + 'challenge', + 'listing', + [1, 2], + { limit: 25, onlyEnabled: true }, + ]) + const queryFn = options.queryFn as unknown as () => Promise + await queryFn() + + expect(apiRequestMock.get).toHaveBeenCalledWith('api/v2/challenges/listing', { + searchParams: { projectIds: '1,2', limit: 25, page: 0, onlyEnabled: true }, + }) + }) + + it('listing GETs the listing endpoint and caches each challenge by id', async () => { + apiRequestMock.get.mockReturnValue({ + json: () => Promise.resolve([makeChallenge(7)]), + }) + + const { result, queryClient } = renderHookWithClient(() => + challengeExplore.listing([1, 2], 20, 1, true) + ) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(apiRequestMock.get).toHaveBeenCalledWith('api/v2/challenges/listing', { + searchParams: { projectIds: '1,2', limit: 20, page: 1, onlyEnabled: true }, + }) + expect(queryClient.getQueryData(['challenge', 7])).toEqual(makeChallenge(7)) + }) + + it('searchChallenges GETs the search endpoint and caches results, disabled for empty search', async () => { + const { result } = renderHookWithClient(() => challengeExplore.searchChallenges()) + + expect(result.current.fetchStatus).toBe('idle') + expect(apiRequestMock.get).not.toHaveBeenCalled() + + apiRequestMock.get.mockReturnValue({ + json: () => Promise.resolve([makeChallenge(8)]), + }) + + const { result: result2, queryClient } = renderHookWithClient(() => + challengeExplore.searchChallenges({ search: 'road' }) + ) + + await waitFor(() => expect(result2.current.isSuccess).toBe(true)) + + expect(apiRequestMock.get).toHaveBeenCalledWith('api/v2/challenges/search', { + searchParams: { search: 'road' }, + }) + expect(queryClient.getQueryData(['challenge', 8])).toEqual(makeChallenge(8)) + }) +}) diff --git a/src/api/challenge/explore.ts b/src/api/challenge/explore.ts index 249f20314..00512d0ac 100644 --- a/src/api/challenge/explore.ts +++ b/src/api/challenge/explore.ts @@ -15,7 +15,7 @@ import type { PreferredChallengesParams, PreferredChallengesResponse, } from '@/types/Challenge' -import { apiRequest, convertParamsToSearchParams } from '../' +import { apiRequest, convertParamsToSearchParams } from '../client' export const challengeExplore = { preferredChallenges: (params: PreferredChallengesParams) => diff --git a/src/api/challenge/favorites.test.tsx b/src/api/challenge/favorites.test.tsx new file mode 100644 index 000000000..3e58f1d06 --- /dev/null +++ b/src/api/challenge/favorites.test.tsx @@ -0,0 +1,79 @@ +import { waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { renderHookWithClient } from '@/test/queryClient' + +const { apiRequestMock } = vi.hoisted(() => ({ + apiRequestMock: { + get: vi.fn(), + post: vi.fn(), + put: vi.fn(), + delete: vi.fn(), + }, +})) + +vi.mock('@/api/client', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, apiRequest: apiRequestMock } +}) + +import { challengeFavorites } from './favorites' + +describe('challengeFavorites', () => { + beforeEach(() => { + apiRequestMock.get.mockReset() + apiRequestMock.post.mockReset() + apiRequestMock.delete.mockReset() + }) + + it('isChallengeFavorited GETs the favorite endpoint for the challenge', async () => { + apiRequestMock.get.mockReturnValue({ json: () => Promise.resolve({ isFavorited: true }) }) + + const { result } = renderHookWithClient(() => challengeFavorites.isChallengeFavorited(5)) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(apiRequestMock.get).toHaveBeenCalledWith('api/v2/challenge/5/favorite') + expect(result.current.data).toEqual({ isFavorited: true }) + }) + + it('isChallengeFavorited is disabled for a falsy challengeId', () => { + const { result } = renderHookWithClient(() => challengeFavorites.isChallengeFavorited(0)) + + expect(result.current.fetchStatus).toBe('idle') + expect(apiRequestMock.get).not.toHaveBeenCalled() + }) + + it('useFavoriteChallenge POSTs to the favorite endpoint and sets isFavorited true in cache', async () => { + apiRequestMock.post.mockReturnValue({ json: () => Promise.resolve(undefined) }) + + const { result, queryClient } = renderHookWithClient(() => + challengeFavorites.useFavoriteChallenge() + ) + + result.current.mutate(5) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(apiRequestMock.post).toHaveBeenCalledWith('api/v2/challenge/5/favorite') + expect(queryClient.getQueryData(['challenge', 5, 'isFavorited'])).toEqual({ + isFavorited: true, + }) + }) + + it('useUnfavoriteChallenge DELETEs the favorite endpoint and sets isFavorited false in cache', async () => { + apiRequestMock.delete.mockReturnValue({ json: () => Promise.resolve(undefined) }) + + const { result, queryClient } = renderHookWithClient(() => + challengeFavorites.useUnfavoriteChallenge() + ) + + result.current.mutate(5) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(apiRequestMock.delete).toHaveBeenCalledWith('api/v2/challenge/5/favorite') + expect(queryClient.getQueryData(['challenge', 5, 'isFavorited'])).toEqual({ + isFavorited: false, + }) + }) +}) diff --git a/src/api/challenge/favorites.ts b/src/api/challenge/favorites.ts index ae10656d2..78b057710 100644 --- a/src/api/challenge/favorites.ts +++ b/src/api/challenge/favorites.ts @@ -1,5 +1,5 @@ import { queryOptions, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' -import { apiRequest } from '../' +import { apiRequest } from '../client' export const challengeFavorites = { isChallengeFavorited: (challengeId: number) => diff --git a/src/api/challenge/index.test.ts b/src/api/challenge/index.test.ts new file mode 100644 index 000000000..ab0568243 --- /dev/null +++ b/src/api/challenge/index.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest' +import { challengeComments } from './comments' +import { challengeExplore } from './explore' +import { challengeFavorites } from './favorites' +import { challenge } from './index' +import { challengeLikes } from './likes' +import { challengeSingle } from './single' + +describe('challenge', () => { + it('merges every source module without any member being overwritten', () => { + const sources = { + ...challengeSingle, + ...challengeExplore, + ...challengeFavorites, + ...challengeLikes, + ...challengeComments, + } + + expect(Object.keys(challenge).sort()).toEqual(Object.keys(sources).sort()) + for (const key of Object.keys(sources)) { + expect(challenge[key as keyof typeof challenge]).toBe(sources[key as keyof typeof sources]) + } + }) + + it('re-exports every member of challengeSingle by identity', () => { + for (const [key, value] of Object.entries(challengeSingle)) { + expect(challenge[key as keyof typeof challenge]).toBe(value) + } + }) + + it('re-exports every member of challengeExplore by identity', () => { + for (const [key, value] of Object.entries(challengeExplore)) { + expect(challenge[key as keyof typeof challenge]).toBe(value) + } + }) + + it('re-exports every member of challengeFavorites by identity', () => { + for (const [key, value] of Object.entries(challengeFavorites)) { + expect(challenge[key as keyof typeof challenge]).toBe(value) + } + }) + + it('re-exports every member of challengeLikes by identity', () => { + for (const [key, value] of Object.entries(challengeLikes)) { + expect(challenge[key as keyof typeof challenge]).toBe(value) + } + }) + + it('re-exports every member of challengeComments by identity', () => { + for (const [key, value] of Object.entries(challengeComments)) { + expect(challenge[key as keyof typeof challenge]).toBe(value) + } + }) + + it('has no key collisions between the merged source modules', () => { + const keySets = [ + Object.keys(challengeSingle), + Object.keys(challengeExplore), + Object.keys(challengeFavorites), + Object.keys(challengeLikes), + Object.keys(challengeComments), + ] + const allKeys = keySets.flat() + const uniqueKeys = new Set(allKeys) + expect(uniqueKeys.size).toBe(allKeys.length) + }) +}) diff --git a/src/api/challenge/likes.test.tsx b/src/api/challenge/likes.test.tsx new file mode 100644 index 000000000..4988d79cd --- /dev/null +++ b/src/api/challenge/likes.test.tsx @@ -0,0 +1,118 @@ +import { waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { renderHookWithClient } from '@/test/queryClient' + +const { apiRequestMock } = vi.hoisted(() => ({ + apiRequestMock: { + get: vi.fn(), + post: vi.fn(), + put: vi.fn(), + delete: vi.fn(), + }, +})) + +vi.mock('@/api/client', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, apiRequest: apiRequestMock } +}) + +import { challengeLikes } from './likes' + +describe('challengeLikes', () => { + beforeEach(() => { + apiRequestMock.get.mockReset() + apiRequestMock.post.mockReset() + apiRequestMock.delete.mockReset() + }) + + it('isChallengeLiked GETs the like endpoint for the challenge', async () => { + apiRequestMock.get.mockReturnValue({ json: () => Promise.resolve({ isLiked: true }) }) + + const { result } = renderHookWithClient(() => challengeLikes.isChallengeLiked(5)) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(apiRequestMock.get).toHaveBeenCalledWith('api/v2/challenge/5/like') + expect(result.current.data).toEqual({ isLiked: true }) + }) + + it('isChallengeLiked is disabled for a falsy challengeId', () => { + const { result } = renderHookWithClient(() => challengeLikes.isChallengeLiked(0)) + + expect(result.current.fetchStatus).toBe('idle') + expect(apiRequestMock.get).not.toHaveBeenCalled() + }) + + it('getChallengeLikeCount GETs the likeCount endpoint for the challenge', async () => { + apiRequestMock.get.mockReturnValue({ json: () => Promise.resolve({ likeCount: 3 }) }) + + const { result } = renderHookWithClient(() => challengeLikes.getChallengeLikeCount(5)) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(apiRequestMock.get).toHaveBeenCalledWith('api/v2/challenge/5/likeCount') + expect(result.current.data).toEqual({ likeCount: 3 }) + }) + + it('getChallengeLikeCount is disabled for a falsy challengeId', () => { + const { result } = renderHookWithClient(() => challengeLikes.getChallengeLikeCount(0)) + + expect(result.current.fetchStatus).toBe('idle') + expect(apiRequestMock.get).not.toHaveBeenCalled() + }) + + it('useLikeChallenge POSTs to the like endpoint, sets isLiked true, and increments likeCount', async () => { + apiRequestMock.post.mockReturnValue({ json: () => Promise.resolve(undefined) }) + + const { result, queryClient } = renderHookWithClient(() => challengeLikes.useLikeChallenge()) + queryClient.setQueryData(['challenge', 5, 'likeCount'], { likeCount: 4 }) + + result.current.mutate(5) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(apiRequestMock.post).toHaveBeenCalledWith('api/v2/challenge/5/like') + expect(queryClient.getQueryData(['challenge', 5, 'isLiked'])).toEqual({ isLiked: true }) + expect(queryClient.getQueryData(['challenge', 5, 'likeCount'])).toEqual({ likeCount: 5 }) + }) + + it('useLikeChallenge treats a missing likeCount cache entry as 0 before incrementing', async () => { + apiRequestMock.post.mockReturnValue({ json: () => Promise.resolve(undefined) }) + + const { result, queryClient } = renderHookWithClient(() => challengeLikes.useLikeChallenge()) + + result.current.mutate(5) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(queryClient.getQueryData(['challenge', 5, 'likeCount'])).toEqual({ likeCount: 1 }) + }) + + it('useUnlikeChallenge DELETEs the like endpoint, sets isLiked false, and decrements likeCount', async () => { + apiRequestMock.delete.mockReturnValue({ json: () => Promise.resolve(undefined) }) + + const { result, queryClient } = renderHookWithClient(() => challengeLikes.useUnlikeChallenge()) + queryClient.setQueryData(['challenge', 5, 'likeCount'], { likeCount: 4 }) + + result.current.mutate(5) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(apiRequestMock.delete).toHaveBeenCalledWith('api/v2/challenge/5/like') + expect(queryClient.getQueryData(['challenge', 5, 'isLiked'])).toEqual({ isLiked: false }) + expect(queryClient.getQueryData(['challenge', 5, 'likeCount'])).toEqual({ likeCount: 3 }) + }) + + it('useUnlikeChallenge never decrements likeCount below 0', async () => { + apiRequestMock.delete.mockReturnValue({ json: () => Promise.resolve(undefined) }) + + const { result, queryClient } = renderHookWithClient(() => challengeLikes.useUnlikeChallenge()) + queryClient.setQueryData(['challenge', 5, 'likeCount'], { likeCount: 0 }) + + result.current.mutate(5) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(queryClient.getQueryData(['challenge', 5, 'likeCount'])).toEqual({ likeCount: 0 }) + }) +}) diff --git a/src/api/challenge/likes.ts b/src/api/challenge/likes.ts index 08a526885..99fd6c51e 100644 --- a/src/api/challenge/likes.ts +++ b/src/api/challenge/likes.ts @@ -1,5 +1,5 @@ import { queryOptions, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' -import { apiRequest } from '../' +import { apiRequest } from '../client' export const challengeLikes = { isChallengeLiked: (challengeId: number) => diff --git a/src/api/challenge/single.test.tsx b/src/api/challenge/single.test.tsx new file mode 100644 index 000000000..62408e6f6 --- /dev/null +++ b/src/api/challenge/single.test.tsx @@ -0,0 +1,609 @@ +import { waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createTestQueryClient, renderHookWithClient } from '@/test/queryClient' +import type { + Challenge, + ChallengeActivityEntry, + ChallengeGetResponse, + ChallengeStatsResponse, + ChallengeTaskMarkersResponse, +} from '@/types/Challenge' +import type { Task } from '@/types/Task' + +const { apiRequestMock } = vi.hoisted(() => ({ + apiRequestMock: { + get: vi.fn(), + post: vi.fn(), + put: vi.fn(), + delete: vi.fn(), + }, +})) + +vi.mock('@/api/client', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, apiRequest: apiRequestMock } +}) + +import { challengeSingle, invalidateChallengeAggregates, patchChallengeTaskMarker } from './single' + +function makeMarkers(): ChallengeTaskMarkersResponse { + return { + markers: [ + { id: 1, status: 0, priority: 0 }, + { id: 2, status: 0, priority: 0 }, + ], + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as unknown as ChallengeTaskMarkersResponse +} + +describe('patchChallengeTaskMarker', () => { + it('does nothing when challengeId is falsy', () => { + const queryClient = createTestQueryClient() + const setSpy = vi.spyOn(queryClient, 'setQueryData') + + patchChallengeTaskMarker(queryClient, 0, 1, { status: 2 }) + + expect(setSpy).not.toHaveBeenCalled() + }) + + it('merges the patch into the matching marker and leaves others untouched', () => { + const queryClient = createTestQueryClient() + queryClient.setQueryData(['challenge', 'taskMarkers', 42], makeMarkers()) + + patchChallengeTaskMarker(queryClient, 42, 1, { status: 2, lockedBy: 7 }) + + const updated = queryClient.getQueryData([ + 'challenge', + 'taskMarkers', + 42, + ]) + const markers = updated?.markers + expect(markers?.[0]).toEqual({ id: 1, status: 2, priority: 0, lockedBy: 7 }) + expect(markers?.[1]).toEqual({ id: 2, status: 0, priority: 0 }) + }) + + it('is a no-op when no marker in the list matches the task id', () => { + const queryClient = createTestQueryClient() + const original = makeMarkers() + queryClient.setQueryData(['challenge', 'taskMarkers', 42], original) + + patchChallengeTaskMarker(queryClient, 42, 999, { status: 2 }) + + const updated = queryClient.getQueryData(['challenge', 'taskMarkers', 42]) + expect(updated).toEqual(original) + }) + + it('is a no-op when the marker list is not cached', () => { + const queryClient = createTestQueryClient() + + patchChallengeTaskMarker(queryClient, 42, 1, { status: 2 }) + + expect(queryClient.getQueryData(['challenge', 'taskMarkers', 42])).toBeUndefined() + }) +}) + +describe('invalidateChallengeAggregates', () => { + it('does nothing when challengeId is falsy', () => { + const queryClient = createTestQueryClient() + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries') + + invalidateChallengeAggregates(queryClient, 0) + + expect(invalidateSpy).not.toHaveBeenCalled() + }) + + it('invalidates the challenge, stats, and activity caches', () => { + const queryClient = createTestQueryClient() + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries') + + invalidateChallengeAggregates(queryClient, 42) + + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['challenge', 42] }) + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['challenge', 'stats', 42] }) + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['challenge', 'activity', 42] }) + }) +}) + +describe('challengeSingle', () => { + beforeEach(() => { + apiRequestMock.get.mockReset() + apiRequestMock.post.mockReset() + apiRequestMock.put.mockReset() + apiRequestMock.delete.mockReset() + }) + + it('getChallenge GETs the challenge by id', async () => { + apiRequestMock.get.mockReturnValue({ json: () => Promise.resolve({ id: 42 }) }) + + const { result } = renderHookWithClient(() => challengeSingle.getChallenge(42)) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(apiRequestMock.get).toHaveBeenCalledWith('api/v2/challenge/42') + expect(result.current.data).toEqual({ id: 42 }) + }) + + it('getChallenge is disabled for a falsy challengeId', () => { + const { result } = renderHookWithClient(() => challengeSingle.getChallenge(0)) + + expect(result.current.fetchStatus).toBe('idle') + expect(apiRequestMock.get).not.toHaveBeenCalled() + }) + + it('getChallengeOptions builds a queryOptions object with the right key and queryFn', async () => { + apiRequestMock.get.mockReturnValue({ json: () => Promise.resolve({ id: 7 }) }) + + const options = challengeSingle.getChallengeOptions(7) + expect(options.queryKey).toEqual(['challenge', 7]) + + const queryFn = options.queryFn as unknown as () => Promise + const data = await queryFn() + + expect(apiRequestMock.get).toHaveBeenCalledWith('api/v2/challenge/7') + expect(data).toEqual({ id: 7 }) + }) + + it('getChallengeTags GETs the tags endpoint keyed by challengeId', async () => { + apiRequestMock.get.mockReturnValue({ json: () => Promise.resolve([{ id: 1, name: 'foo' }]) }) + + const { result } = renderHookWithClient(() => challengeSingle.getChallengeTags(42)) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(apiRequestMock.get).toHaveBeenCalledWith('api/v2/challenge/42/tags') + expect(result.current.data).toEqual([{ id: 1, name: 'foo' }]) + }) + + it('getChallengeStats GETs the data endpoint keyed by challengeId', async () => { + const stats = { total: 10 } as unknown as ChallengeStatsResponse + apiRequestMock.get.mockReturnValue({ json: () => Promise.resolve(stats) }) + + const { result } = renderHookWithClient(() => challengeSingle.getChallengeStats(42)) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(apiRequestMock.get).toHaveBeenCalledWith('api/v2/data/challenge/42') + expect(result.current.data).toEqual(stats) + }) + + it('getChallengeActivity GETs the activity endpoint with an abort signal', async () => { + const activity = [ + { date: '2024-01-01', status: 1, statusName: 'Fixed', count: 3 }, + ] as ChallengeActivityEntry[] + apiRequestMock.get.mockReturnValue({ json: () => Promise.resolve(activity) }) + + const { result } = renderHookWithClient(() => challengeSingle.getChallengeActivity(42)) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(apiRequestMock.get).toHaveBeenCalledWith( + 'api/v2/data/challenge/42/activity', + expect.objectContaining({ signal: expect.any(AbortSignal) }) + ) + expect(result.current.data).toEqual(activity) + }) + + it('getChallengeTaskMarkersOptions builds a queryOptions object for the taskMarkers endpoint', async () => { + const markers = makeMarkers() + apiRequestMock.get.mockReturnValue({ json: () => Promise.resolve(markers) }) + + const options = challengeSingle.getChallengeTaskMarkersOptions(42) + expect(options.queryKey).toEqual(['challenge', 'taskMarkers', 42]) + expect(options.enabled).toBe(true) + + const queryFn = options.queryFn as unknown as () => Promise + const data = await queryFn() + + expect(apiRequestMock.get).toHaveBeenCalledWith('api/v2/challenge/42/taskMarkers') + expect(data).toEqual(markers) + }) + + it('getChallengeTaskMarkers uses the same options as getChallengeTaskMarkersOptions', async () => { + apiRequestMock.get.mockReturnValue({ json: () => Promise.resolve(makeMarkers()) }) + + const { result } = renderHookWithClient(() => challengeSingle.getChallengeTaskMarkers(42)) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(apiRequestMock.get).toHaveBeenCalledWith('api/v2/challenge/42/taskMarkers') + }) + + it('getRandomTask GETs a single random task and caches it', async () => { + const tasks = [{ id: 99 }] as unknown as Task[] + apiRequestMock.get.mockReturnValue({ json: () => Promise.resolve(tasks) }) + const queryClient = createTestQueryClient() + + const result = await challengeSingle.getRandomTask(42, queryClient) + + expect(apiRequestMock.get).toHaveBeenCalledWith('api/v2/challenge/42/tasks/random', { + searchParams: { limit: 1 }, + }) + expect(result).toEqual(tasks) + expect(queryClient.getQueryData(['task', 99])).toEqual({ id: 99 }) + }) + + it('fetchTasksNearby GETs the tasksNearby endpoint with default limit', async () => { + const tasks = [{ id: 1 }] as unknown as Task[] + apiRequestMock.get.mockReturnValue({ json: () => Promise.resolve(tasks) }) + + const result = await challengeSingle.fetchTasksNearby(42, 5) + + expect(apiRequestMock.get).toHaveBeenCalledWith('api/v2/challenge/42/tasksNearby/5', { + searchParams: { excludeSelfLocked: 'true', limit: '5' }, + }) + expect(result).toEqual(tasks) + }) + + it('fetchTasksNearby honors a custom limit', async () => { + apiRequestMock.get.mockReturnValue({ json: () => Promise.resolve([]) }) + + await challengeSingle.fetchTasksNearby(42, 5, 20) + + expect(apiRequestMock.get).toHaveBeenCalledWith('api/v2/challenge/42/tasksNearby/5', { + searchParams: { excludeSelfLocked: 'true', limit: '20' }, + }) + }) + + it('getTasksNearby GETs the tasksNearby endpoint and caches each task by id', async () => { + const tasks = [{ id: 11 }, { id: 12 }] as unknown as Task[] + apiRequestMock.get.mockReturnValue({ json: () => Promise.resolve(tasks) }) + + const { result, queryClient } = renderHookWithClient(() => + challengeSingle.getTasksNearby(42, 5, 3) + ) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(apiRequestMock.get).toHaveBeenCalledWith('api/v2/challenge/42/tasksNearby/5', { + searchParams: { excludeSelfLocked: 'true', limit: '3' }, + }) + expect(queryClient.getQueryData(['task', 11])).toEqual({ id: 11 }) + expect(queryClient.getQueryData(['task', 12])).toEqual({ id: 12 }) + }) + + it('getTasksNearby is disabled when challengeId or taskId is falsy', () => { + const { result } = renderHookWithClient(() => challengeSingle.getTasksNearby(0, 5)) + + expect(result.current.fetchStatus).toBe('idle') + expect(apiRequestMock.get).not.toHaveBeenCalled() + }) + + it('useCloneChallenge PUTs to the clone endpoint with an encoded name and invalidates listings', async () => { + apiRequestMock.put.mockReturnValue({ json: () => Promise.resolve({ id: 100 }) }) + + const { result, queryClient } = renderHookWithClient(() => challengeSingle.useCloneChallenge()) + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries') + + result.current.mutate({ challengeId: 42, newName: 'a/b c' }) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(apiRequestMock.put).toHaveBeenCalledWith('api/v2/challenge/42/clone/a%2Fb%20c') + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['challenge'] }) + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['challenge', 'managed'] }) + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['project', 'challenges'] }) + }) + + it('useCreateChallenge POSTs a normalized body, applying defaults and omitting id', async () => { + apiRequestMock.post.mockReturnValue({ json: () => Promise.resolve({ id: 55 }) }) + + const { result, queryClient } = renderHookWithClient(() => challengeSingle.useCreateChallenge()) + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries') + + result.current.mutate({ + projectId: 3, + challengeData: { id: 999, name: 'My Challenge' } as unknown as Partial, + }) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(apiRequestMock.post).toHaveBeenCalledWith('api/v2/challenge', { + json: { + parent: 3, + name: 'My Challenge', + description: '', + instruction: '', + difficulty: 2, + enabled: true, + featured: false, + overpassQL: '', + overpassTargetType: '', + }, + }) + expect(queryClient.getQueryData(['challenge', 55])).toEqual({ id: 55 }) + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['project', 'challenges', 3] }) + }) + + it('useCreateChallenge forwards localGeoJSON and dataOriginDate only when provided', async () => { + apiRequestMock.post.mockReturnValue({ json: () => Promise.resolve({ id: 56 }) }) + + const { result } = renderHookWithClient(() => challengeSingle.useCreateChallenge()) + + result.current.mutate({ + projectId: 3, + challengeData: { + name: 'x', + localGeoJSON: '{"type":"FeatureCollection"}', + dataOriginDate: '2024-01-01', + } as unknown as Partial, + }) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(apiRequestMock.post).toHaveBeenCalledWith( + 'api/v2/challenge', + expect.objectContaining({ + json: expect.objectContaining({ + localGeoJSON: '{"type":"FeatureCollection"}', + dataOriginDate: '2024-01-01', + }), + }) + ) + }) + + it('useUpdateChallenge PUTs the updates with id and invalidates related caches', async () => { + apiRequestMock.put.mockReturnValue({ json: () => Promise.resolve({ id: 42, name: 'new' }) }) + + const { result, queryClient } = renderHookWithClient(() => challengeSingle.useUpdateChallenge()) + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries') + + result.current.mutate({ challengeId: 42, updates: { name: 'new' } }) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(apiRequestMock.put).toHaveBeenCalledWith('api/v2/challenge/42', { + json: { id: 42, name: 'new' }, + }) + expect(queryClient.getQueryData(['challenge', 42])).toEqual({ id: 42, name: 'new' }) + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['project', 'challenges'] }) + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['challenge', 'explore'] }) + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['challenge', 'exploreInfinite'] }) + }) + + it('useSaveOrUpdateChallenge POSTs the challenge and invalidates related caches', async () => { + apiRequestMock.post.mockReturnValue({ json: () => Promise.resolve({ id: 42 }) }) + + const { result, queryClient } = renderHookWithClient(() => + challengeSingle.useSaveOrUpdateChallenge() + ) + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries') + + result.current.mutate({ id: 42 } as unknown as Partial) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(apiRequestMock.post).toHaveBeenCalledWith('api/v2/challenge/saveOrUpdate', { + json: { id: 42 }, + }) + expect(queryClient.getQueryData(['challenge', 42])).toEqual({ id: 42 }) + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['project', 'challenges'] }) + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['challenge', 'explore'] }) + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['challenge', 'exploreInfinite'] }) + }) + + it('useUpdatePriorities PUTs the priorities and invalidates taskMarkers and task caches', async () => { + apiRequestMock.put.mockReturnValue({ json: () => Promise.resolve({ id: 42 }) }) + + const { result, queryClient } = renderHookWithClient(() => + challengeSingle.useUpdatePriorities() + ) + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries') + + const priorities = { + defaultPriority: 0, + highPriorityRule: '', + highPriorityBounds: '', + mediumPriorityRule: '', + mediumPriorityBounds: '', + lowPriorityRule: '', + lowPriorityBounds: '', + } + result.current.mutate({ challengeId: 42, priorities }) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(apiRequestMock.put).toHaveBeenCalledWith('api/v2/challenge/42/priorities', { + json: priorities, + }) + expect(queryClient.getQueryData(['challenge', 42])).toEqual({ id: 42 }) + expect(invalidateSpy).toHaveBeenCalledWith({ + queryKey: ['challenge', 'taskMarkers', 42], + }) + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['task'] }) + }) + + it('usePreviewPriorities POSTs a preview request keyed by challengeId and draft', async () => { + const preview = { priorities: {}, counts: { high: 0, medium: 0, low: 0 } } + apiRequestMock.post.mockReturnValue({ json: () => Promise.resolve(preview) }) + + const draft = { + defaultPriority: 0, + highPriorityRule: '', + highPriorityBounds: '', + mediumPriorityRule: '', + mediumPriorityBounds: '', + lowPriorityRule: '', + lowPriorityBounds: '', + } + const { result } = renderHookWithClient(() => challengeSingle.usePreviewPriorities(42, draft)) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(apiRequestMock.post).toHaveBeenCalledWith('api/v2/challenge/42/priorities/preview', { + json: draft, + }) + expect(result.current.data).toEqual(preview) + }) + + it('usePreviewPriorities is disabled when draft is null or challengeId is not positive', () => { + const { result } = renderHookWithClient(() => challengeSingle.usePreviewPriorities(42, null)) + + expect(result.current.fetchStatus).toBe('idle') + expect(apiRequestMock.post).not.toHaveBeenCalled() + + const { result: result2 } = renderHookWithClient(() => + challengeSingle.usePreviewPriorities(0, { + defaultPriority: 0, + highPriorityRule: '', + highPriorityBounds: '', + mediumPriorityRule: '', + mediumPriorityBounds: '', + lowPriorityRule: '', + lowPriorityBounds: '', + }) + ) + + expect(result2.current.fetchStatus).toBe('idle') + expect(apiRequestMock.post).not.toHaveBeenCalled() + }) + + it('useUploadGeoJSON PUTs FormData with computed search params and invalidates markers/stats', async () => { + apiRequestMock.put.mockReturnValue({ json: () => Promise.resolve(undefined) }) + + const { result, queryClient } = renderHookWithClient(() => challengeSingle.useUploadGeoJSON()) + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries') + + const file = new File(['{}'], 'tasks.json', { type: 'application/json' }) + result.current.mutate({ + challengeId: 42, + geoJSONFile: file, + options: { lineByLine: true, dataOriginDate: '2024-01-01' }, + }) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(apiRequestMock.put).toHaveBeenCalledTimes(1) + const [url, requestOptions] = apiRequestMock.put.mock.calls[0] + expect(url).toBe('api/v2/challenge/42/addFileTasks') + expect(requestOptions.searchParams).toEqual({ + lineByLine: 'true', + removeUnmatched: 'false', + skipSnapshot: 'true', + dataOriginDate: '2024-01-01', + }) + expect(requestOptions.body).toBeInstanceOf(FormData) + expect(requestOptions.body.get('json')).toBe(file) + + expect(invalidateSpy).toHaveBeenCalledWith({ + queryKey: ['challenge', 'taskMarkers', 42], + }) + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['challenge', 'stats', 42] }) + }) + + it('useUploadGeoJSON omits dataOriginDate from search params when not provided', async () => { + apiRequestMock.put.mockReturnValue({ json: () => Promise.resolve(undefined) }) + + const { result } = renderHookWithClient(() => challengeSingle.useUploadGeoJSON()) + + const file = new File(['{}'], 'tasks.json') + result.current.mutate({ challengeId: 42, geoJSONFile: file }) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + const [, requestOptions] = apiRequestMock.put.mock.calls[0] + expect(requestOptions.searchParams).toEqual({ + lineByLine: 'false', + removeUnmatched: 'false', + skipSnapshot: 'true', + }) + }) + + it('refreshChallenge invalidates challenge, taskMarkers, stats, and activity caches', async () => { + const queryClient = createTestQueryClient() + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries') + + await challengeSingle.refreshChallenge(42, queryClient) + + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['challenge', 42] }) + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['challenge', 'taskMarkers', 42] }) + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['challenge', 'stats', 42] }) + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['challenge', 'activity', 42] }) + }) + + it('useMoveChallenge POSTs to the project move endpoint and invalidates caches', async () => { + apiRequestMock.post.mockReturnValue({ json: () => Promise.resolve(undefined) }) + + const { result, queryClient } = renderHookWithClient(() => challengeSingle.useMoveChallenge()) + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries') + + result.current.mutate({ challengeId: 42, toProjectId: 9 }) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(apiRequestMock.post).toHaveBeenCalledWith('api/v2/challenge/42/project/9') + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['challenge', 42] }) + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['project', 'challenges'] }) + }) + + it('useDeleteChallenge DELETEs the challenge, removes it from cache, and invalidates lists', async () => { + apiRequestMock.delete.mockReturnValue(Promise.resolve(undefined)) + + const { result, queryClient } = renderHookWithClient(() => challengeSingle.useDeleteChallenge()) + const removeSpy = vi.spyOn(queryClient, 'removeQueries') + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries') + + result.current.mutate(42) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(apiRequestMock.delete).toHaveBeenCalledWith('api/v2/challenge/42') + expect(result.current.data).toEqual({ challengeId: 42 }) + expect(removeSpy).toHaveBeenCalledWith({ queryKey: ['challenge', 42] }) + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['project', 'challenges'] }) + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['challenge', 'listing'] }) + }) + + it('useArchiveChallenge POSTs the archive flag and invalidates caches', async () => { + apiRequestMock.post.mockReturnValue({ json: () => Promise.resolve(undefined) }) + + const { result, queryClient } = renderHookWithClient(() => + challengeSingle.useArchiveChallenge() + ) + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries') + + result.current.mutate({ challengeId: 42, isArchived: true }) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(apiRequestMock.post).toHaveBeenCalledWith('api/v2/challenge/42/archive', { + json: { isArchived: true }, + }) + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['challenge', 42] }) + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['project', 'challenges'] }) + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['challenge', 'listing'] }) + }) + + it('useRebuildChallenge PUTs with only the provided boolean search params set', async () => { + apiRequestMock.put.mockReturnValue({ json: () => Promise.resolve(undefined) }) + + const { result, queryClient } = renderHookWithClient(() => + challengeSingle.useRebuildChallenge() + ) + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries') + + result.current.mutate({ challengeId: 42, removeUnmatched: true }) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(apiRequestMock.put).toHaveBeenCalledWith('api/v2/challenge/42/rebuild', { + searchParams: { removeUnmatched: 'true' }, + }) + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['challenge', 42] }) + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['project', 'challenges'] }) + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['challenge', 'stats', 42] }) + }) + + it('useRebuildChallenge omits search params that are not provided', async () => { + apiRequestMock.put.mockReturnValue({ json: () => Promise.resolve(undefined) }) + + const { result } = renderHookWithClient(() => challengeSingle.useRebuildChallenge()) + + result.current.mutate({ challengeId: 42 }) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(apiRequestMock.put).toHaveBeenCalledWith('api/v2/challenge/42/rebuild', { + searchParams: {}, + }) + }) +}) diff --git a/src/api/challenge/single.ts b/src/api/challenge/single.ts index 87eca19ce..c1c5fcb7d 100644 --- a/src/api/challenge/single.ts +++ b/src/api/challenge/single.ts @@ -13,7 +13,7 @@ import type { ChallengeTaskMarkersResponse, } from '@/types/Challenge' import type { Task } from '@/types/Task' -import { apiRequest } from '../' +import { apiRequest } from '../client' /** * Surgically update a single task's entry in the cached `taskMarkers` list for diff --git a/src/api/client.ts b/src/api/client.ts new file mode 100644 index 000000000..e91b29273 --- /dev/null +++ b/src/api/client.ts @@ -0,0 +1,62 @@ +import ky from 'ky' + +export const apiKey = window.env.VITE_SERVER_API_KEY + +export const apiRequest = ky.extend({ + prefixUrl: window.env.VITE_API_BASE_URL || 'http://127.0.0.1:9000', + credentials: 'include', + timeout: 60000, + retry: { + limit: 0, + }, + hooks: { + beforeRequest: [ + (request) => { + // Don't clobber Content-Type already set by ky (for json bodies) or by + // the Request constructor (for FormData/URLSearchParams, which carries + // the multipart boundary). Overwriting either breaks the body. + if (!request.headers.has('content-type')) { + request.headers.set('Content-Type', 'application/json') + } + if (apiKey) { + request.headers.set('apiKey', apiKey) + } + }, + ], + }, +}) + +export const convertParamsToSearchParams = ( + params: Record< + string, + | string + | number + | boolean + | Record + | Array + | null + | undefined + > +): Record => { + const searchParams: Record = {} + + Object.entries(params).forEach(([key, value]) => { + if (value === null || value === undefined) { + return + } + + if (typeof value === 'string') { + searchParams[key] = value + } else if (typeof value === 'number') { + searchParams[key] = value + } else if (typeof value === 'boolean') { + searchParams[key] = value + } else if (Array.isArray(value)) { + searchParams[key] = value.map((item) => item.toString()).join(',') + } else if (typeof value === 'object') { + searchParams[key] = JSON.stringify(value) + } + }) + + return searchParams +} diff --git a/src/api/index.test.ts b/src/api/index.test.ts new file mode 100644 index 000000000..e62a73f5d --- /dev/null +++ b/src/api/index.test.ts @@ -0,0 +1,145 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { apiKey, apiRequest, convertParamsToSearchParams } from './index' + +describe('convertParamsToSearchParams', () => { + it('passes through string values unchanged', () => { + expect(convertParamsToSearchParams({ name: 'hello' })).toEqual({ name: 'hello' }) + }) + + it('passes through number values unchanged', () => { + expect(convertParamsToSearchParams({ limit: 10 })).toEqual({ limit: 10 }) + }) + + it('passes through zero as a number, not omitted', () => { + expect(convertParamsToSearchParams({ page: 0 })).toEqual({ page: 0 }) + }) + + it('passes through boolean values unchanged, including false', () => { + expect(convertParamsToSearchParams({ onlyEnabled: true, onlyOwned: false })).toEqual({ + onlyEnabled: true, + onlyOwned: false, + }) + }) + + it('joins array values into a comma-separated string', () => { + expect(convertParamsToSearchParams({ ids: [1, 2, 3] })).toEqual({ ids: '1,2,3' }) + }) + + it('joins arrays of mixed string/number/boolean items via toString', () => { + expect(convertParamsToSearchParams({ mixed: ['a', 2, false] })).toEqual({ mixed: 'a,2,false' }) + }) + + it('joins an empty array into an empty string', () => { + expect(convertParamsToSearchParams({ empty: [] })).toEqual({ empty: '' }) + }) + + it('JSON-stringifies plain object values', () => { + expect(convertParamsToSearchParams({ filter: { status: 'open' } })).toEqual({ + filter: JSON.stringify({ status: 'open' }), + }) + }) + + it('omits keys whose value is null', () => { + expect(convertParamsToSearchParams({ search: null })).toEqual({}) + }) + + it('omits keys whose value is undefined', () => { + expect(convertParamsToSearchParams({ search: undefined })).toEqual({}) + }) + + it('handles a mix of types in a single call, omitting only null/undefined', () => { + const result = convertParamsToSearchParams({ + name: 'hello', + limit: 5, + onlyEnabled: true, + ids: [1, 2], + filter: { a: 1 }, + skipMe: null, + alsoSkip: undefined, + }) + + expect(result).toEqual({ + name: 'hello', + limit: 5, + onlyEnabled: true, + ids: '1,2', + filter: JSON.stringify({ a: 1 }), + }) + }) + + it('returns an empty object when given an empty input', () => { + expect(convertParamsToSearchParams({})).toEqual({}) + }) +}) + +describe('apiKey', () => { + it('is read from window.env.VITE_SERVER_API_KEY', () => { + expect(apiKey).toBe(window.env.VITE_SERVER_API_KEY) + }) +}) + +// window.env's fields are declared read-only (they're meant to be set once at +// app boot from env.json), but this test needs to simulate a deployment with +// no configured API key, so it writes through a narrow mutable view instead +// of widening with `as any`. +type MutableEnv = { VITE_SERVER_API_KEY?: string } + +function stubFetch(response: Response) { + const fetchMock = vi.fn(async (_request: Request) => response) + vi.stubGlobal('fetch', fetchMock) + return fetchMock +} + +describe('apiRequest beforeRequest hook', () => { + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('sets a default Content-Type of application/json', async () => { + const fetchMock = stubFetch(new Response(JSON.stringify({}), { status: 200 })) + + await apiRequest.get('some/path').json() + + expect(fetchMock).toHaveBeenCalledTimes(1) + const [request] = fetchMock.mock.calls[0] + expect(request.headers.get('content-type')).toBe('application/json') + }) + + it('sets the apiKey header when an API key is configured', async () => { + const fetchMock = stubFetch(new Response(JSON.stringify({}), { status: 200 })) + + await apiRequest.get('some/path').json() + + const [request] = fetchMock.mock.calls[0] + expect(request.headers.get('apikey')).toBe(apiKey) + }) + + it('does not overwrite a content-type header already set on the request', async () => { + const fetchMock = stubFetch(new Response(null, { status: 200 })) + + await apiRequest.post('upload', { headers: { 'content-type': 'text/plain' } }) + + const [request] = fetchMock.mock.calls[0] + expect(request.headers.get('content-type')).toBe('text/plain') + }) + + it('omits the apiKey header when no API key is configured', async () => { + const mutableEnv = window.env as unknown as MutableEnv + const originalKey = mutableEnv.VITE_SERVER_API_KEY + mutableEnv.VITE_SERVER_API_KEY = undefined + vi.resetModules() + + try { + const { apiRequest: freshApiRequest } = await import('./index') + const fetchMock = stubFetch(new Response(JSON.stringify({}), { status: 200 })) + + await freshApiRequest.get('some/path').json() + + const [request] = fetchMock.mock.calls[0] + expect(request.headers.has('apikey')).toBe(false) + } finally { + mutableEnv.VITE_SERVER_API_KEY = originalKey + vi.resetModules() + } + }) +}) diff --git a/src/api/index.ts b/src/api/index.ts index 6768bfc98..065507388 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -1,4 +1,3 @@ -import ky from 'ky' import { challenge } from './challenge' import { osm } from './osm' import { project } from './project' @@ -21,63 +20,10 @@ export const api = { team, } -export const apiKey = window.env.VITE_SERVER_API_KEY - -export const apiRequest = ky.extend({ - prefixUrl: window.env.VITE_API_BASE_URL || 'http://127.0.0.1:9000', - credentials: 'include', - timeout: 60000, - retry: { - limit: 0, - }, - hooks: { - beforeRequest: [ - (request) => { - // Don't clobber Content-Type already set by ky (for json bodies) or by - // the Request constructor (for FormData/URLSearchParams, which carries - // the multipart boundary). Overwriting either breaks the body. - if (!request.headers.has('content-type')) { - request.headers.set('Content-Type', 'application/json') - } - if (apiKey) { - request.headers.set('apiKey', apiKey) - } - }, - ], - }, -}) - -export const convertParamsToSearchParams = ( - params: Record< - string, - | string - | number - | boolean - | Record - | Array - | null - | undefined - > -): Record => { - const searchParams: Record = {} - - Object.entries(params).forEach(([key, value]) => { - if (value === null || value === undefined) { - return - } - - if (typeof value === 'string') { - searchParams[key] = value - } else if (typeof value === 'number') { - searchParams[key] = value - } else if (typeof value === 'boolean') { - searchParams[key] = value - } else if (Array.isArray(value)) { - searchParams[key] = value.map((item) => item.toString()).join(',') - } else if (typeof value === 'object') { - searchParams[key] = JSON.stringify(value) - } - }) - - return searchParams -} +// Re-exported for backward compatibility. Feature modules under src/api/** +// must import these from './client' (or '../client'), not from here or from +// '@/api' — importing from this file creates a circular dependency (this +// file imports every feature module to build `api` above), which can leave +// an aggregator like challenge/index.ts's spread-merge silently missing a +// submodule's exports depending on module load order. +export { apiKey, apiRequest, convertParamsToSearchParams } from './client' diff --git a/src/api/osm.test.ts b/src/api/osm.test.ts new file mode 100644 index 000000000..ede4a7721 --- /dev/null +++ b/src/api/osm.test.ts @@ -0,0 +1,345 @@ +import { Window } from 'happy-dom' +import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest' +import { osm } from './osm' + +// osm.ts relies on the browser's DOMParser for the XML-based endpoints. The +// `unit` vitest project runs in a plain Node environment (no DOM), so we +// install happy-dom's DOMParser implementation as a global for this file only. +beforeAll(() => { + const happyDomWindow = new Window() + vi.stubGlobal('DOMParser', happyDomWindow.DOMParser) +}) + +afterEach(() => { + vi.unstubAllGlobals() + vi.stubGlobal('DOMParser', new Window().DOMParser) +}) + +const OSM_SERVER = 'https://www.openstreetmap.org' +const OSM_API_SERVER = 'https://api.openstreetmap.org' + +function stubFetch(implementation: (uri: string) => Promise | Response) { + const fetchMock = vi.fn(async (uri: string) => implementation(uri)) + vi.stubGlobal('fetch', fetchMock) + return fetchMock +} + +describe('osm', () => { + describe('getOSMServerUrl / getOSMApiServerUrl', () => { + it('returns the configured (default) OSM server URLs', () => { + expect(osm.getOSMServerUrl()).toBe(OSM_SERVER) + expect(osm.getOSMApiServerUrl()).toBe(OSM_API_SERVER) + }) + }) + + describe('osmUserProfileURL', () => { + it('builds a profile URL for a simple username', () => { + expect(osm.osmUserProfileURL('someuser')).toBe(`${OSM_SERVER}/user/someuser`) + }) + + it('URL-encodes special characters in the username', () => { + expect(osm.osmUserProfileURL('john doe/weird')).toBe( + `${OSM_SERVER}/user/${encodeURIComponent('john doe/weird')}` + ) + }) + }) + + describe('validateBBoxArea', () => { + it('accepts a small, valid bounding box', () => { + expect(osm.validateBBoxArea('-0.1,-0.1,0.1,0.1')).toEqual({ isValid: true }) + }) + + it('rejects a bounding box with non-numeric components', () => { + expect(osm.validateBBoxArea('a,b,c,d')).toEqual({ + isValid: false, + error: 'Invalid bounding box format', + }) + }) + + it('treats a missing component as a non-finite area rather than an invalid format', () => { + // A missing 4th component destructures to `undefined`, and `Number.isNaN(undefined)` + // is false, so this doesn't hit the "Invalid bounding box format" branch. The + // resulting area is NaN, which also fails the `area > MAX_OSM_AREA` check, so the + // box is (surprisingly) reported as valid. + expect(osm.validateBBoxArea('1,2,3')).toEqual({ isValid: true }) + }) + + it('rejects a bounding box whose area exceeds the maximum allowed', () => { + const result = osm.validateBBoxArea('0,0,1,1') + expect(result.isValid).toBe(false) + expect(result.error).toContain('too large') + expect(result.error).toContain('Maximum area: 0.25 square degrees') + expect(result.error).toContain('Current area: 1.0000 square degrees') + }) + }) + + describe('fetchOSMData', () => { + it('throws without fetching when the bounding box is invalid', async () => { + const fetchMock = stubFetch(() => new Response('', { status: 200 })) + + await expect(osm.fetchOSMData('a,b,c,d')).rejects.toThrow('Invalid bounding box format') + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('throws without fetching when the bounding box area is too large', async () => { + const fetchMock = stubFetch(() => new Response('', { status: 200 })) + + await expect(osm.fetchOSMData('0,0,1,1')).rejects.toThrow(/too large/) + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('fetches and parses the map XML for a valid bounding box', async () => { + const xml = '' + const fetchMock = stubFetch(() => new Response(xml, { status: 200 })) + + const doc = await osm.fetchOSMData('-0.1,-0.1,0.1,0.1') + + expect(fetchMock).toHaveBeenCalledWith(`${OSM_API_SERVER}/api/0.6/map?bbox=-0.1,-0.1,0.1,0.1`) + expect(doc.querySelector('node')?.getAttribute('id')).toBe('1') + }) + + it('throws a mapped error message when the fetch fails', async () => { + stubFetch(() => new Response('', { status: 400 })) + + await expect(osm.fetchOSMData('-0.1,-0.1,0.1,0.1')).rejects.toThrow( + 'Request too large - please zoom in further' + ) + }) + }) + + describe('fetchOSMElement', () => { + const xml = + '' + + it('returns a normalized JSON element by default', async () => { + stubFetch(() => new Response(xml, { status: 200 })) + + const element = await osm.fetchOSMElement('node/123') + + expect(element).toEqual({ + id: 123, + lat: 1.5, + visible: true, + tag: [ + { k: 'highway', v: 'residential' }, + { k: 'oneway', v: 'yes' }, + ], + }) + }) + + it('returns the raw XML Document when asXML is true', async () => { + stubFetch(() => new Response(xml, { status: 200 })) + + const doc = await osm.fetchOSMElement('node/123', true) + + expect(doc).not.toBeNull() + expect((doc as Document).querySelector('node')?.getAttribute('id')).toBe('123') + }) + + it('returns null when the requested element type is not present in the response', async () => { + stubFetch(() => new Response('', { status: 200 })) + + const element = await osm.fetchOSMElement('way/999') + + expect(element).toBeNull() + }) + + it('fetches from the correct URI using the type/id path', async () => { + const fetchMock = stubFetch(() => new Response(xml, { status: 200 })) + + await osm.fetchOSMElement('node/123') + + expect(fetchMock).toHaveBeenCalledWith(`${OSM_API_SERVER}/api/0.6/node/123`) + }) + + it('throws a mapped error message for a 404 response', async () => { + stubFetch(() => new Response('', { status: 404 })) + + await expect(osm.fetchOSMElement('node/123')).rejects.toThrow('Element not found') + }) + + it('throws a mapped error message for a 410 response', async () => { + stubFetch(() => new Response('', { status: 410 })) + + await expect(osm.fetchOSMElement('node/123')).rejects.toThrow('Element has been deleted') + }) + + it('throws a generic message for an unmapped error status', async () => { + stubFetch(() => new Response('', { status: 500, statusText: 'Internal Server Error' })) + + await expect(osm.fetchOSMElement('node/123')).rejects.toThrow( + 'OSM API error: Internal Server Error' + ) + }) + }) + + describe('fetchOSMElementHistory', () => { + it('returns null without fetching when idString is empty', async () => { + const fetchMock = stubFetch(() => new Response('{}', { status: 200 })) + + const result = await osm.fetchOSMElementHistory('') + + expect(result).toBeNull() + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('fetches history JSON from the correct URI', async () => { + const fetchMock = stubFetch( + () => new Response(JSON.stringify({ elements: [] }), { status: 200 }) + ) + + await osm.fetchOSMElementHistory('way/55') + + expect(fetchMock).toHaveBeenCalledWith(`${OSM_API_SERVER}/api/0.6/way/55/history.json`) + }) + + it('returns the raw elements when includeChangesets is false', async () => { + const elements = [ + { type: 'way', id: 55, version: 1, changeset: 10, timestamp: 't', user: 'u', uid: 1 }, + ] + stubFetch(() => new Response(JSON.stringify({ elements }), { status: 200 })) + + const result = await osm.fetchOSMElementHistory('way/55', false) + + expect(result).toEqual(elements) + }) + + it('defaults to an empty array when the response has no elements', async () => { + stubFetch(() => new Response(JSON.stringify({}), { status: 200 })) + + const result = await osm.fetchOSMElementHistory('way/55') + + expect(result).toEqual([]) + }) + + it('merges full changeset data into each history entry when includeChangesets is true', async () => { + const elements = [ + { type: 'way', id: 55, version: 1, changeset: 10, timestamp: 't1', user: 'u', uid: 1 }, + { type: 'way', id: 55, version: 2, changeset: 10, timestamp: 't2', user: 'u', uid: 1 }, + { type: 'way', id: 55, version: 3, changeset: 20, timestamp: 't3', user: 'u', uid: 1 }, + ] + + const fetchMock = vi.fn(async (uri: string) => { + if (uri.endsWith('history.json')) { + return new Response(JSON.stringify({ elements }), { status: 200 }) + } + // fetchOSMChangesets uses fetchXMLData -> fetch + DOMParser + expect(uri).toBe(`${OSM_API_SERVER}/api/0.6/changesets?changesets=10,20`) + const xml = + '' + return new Response(xml, { status: 200 }) + }) + vi.stubGlobal('fetch', fetchMock) + + const result = await osm.fetchOSMElementHistory('way/55', true) + + expect(result).toEqual([ + { + ...elements[0], + changeset: { id: 10, user: 'alice', open: false }, + }, + { + ...elements[1], + changeset: { id: 10, user: 'alice', open: false }, + }, + { + ...elements[2], + changeset: { id: 20, user: 'bob', open: true }, + }, + ]) + }) + + it('throws a mapped error message when the history fetch fails', async () => { + stubFetch(() => new Response('', { status: 509 })) + + await expect(osm.fetchOSMElementHistory('way/55')).rejects.toThrow( + 'Bandwidth limit exceeded - please try again later' + ) + }) + }) + + describe('fetchOSMChangesets', () => { + it('returns an empty array without fetching when given no changeset ids', async () => { + const fetchMock = stubFetch(() => new Response('', { status: 200 })) + + const result = await osm.fetchOSMChangesets([]) + + expect(result).toEqual([]) + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('fetches and normalizes multiple changesets', async () => { + const xml = + '' + const fetchMock = stubFetch(() => new Response(xml, { status: 200 })) + + const result = await osm.fetchOSMChangesets([1, 2]) + + expect(fetchMock).toHaveBeenCalledWith(`${OSM_API_SERVER}/api/0.6/changesets?changesets=1,2`) + expect(result).toEqual([ + { id: 1, user: 'alice', open: false }, + { id: 2, user: 'bob', open: true }, + ]) + }) + }) + + describe('fetchOSMUser', () => { + it('returns the display name parsed out of the XML response', async () => { + stubFetch( + () => + new Response('', { + status: 200, + }) + ) + + const result = await osm.fetchOSMUser(7) + + expect(result).toEqual({ id: 7, displayName: 'alice' }) + }) + + it('returns a null display name when it cannot be parsed from a successful response', async () => { + stubFetch(() => new Response('', { status: 200 })) + + const result = await osm.fetchOSMUser(7) + + expect(result).toEqual({ id: 7, displayName: null }) + }) + + it('returns a null display name (not an error) for a 404 response', async () => { + stubFetch(() => new Response('', { status: 404 })) + + const result = await osm.fetchOSMUser(999) + + expect(result).toEqual({ id: 999, displayName: null }) + }) + + it('throws a mapped error message for other failure statuses', async () => { + stubFetch(() => new Response('', { status: 509 })) + + await expect(osm.fetchOSMUser(7)).rejects.toThrow( + 'Bandwidth limit exceeded - please try again later' + ) + }) + + it('fetches from the correct user URI', async () => { + const fetchMock = stubFetch(() => new Response('', { status: 200 })) + + await osm.fetchOSMUser(7) + + expect(fetchMock).toHaveBeenCalledWith(`${OSM_API_SERVER}/api/0.6/user/7`) + }) + }) + + describe('getBBoxString', () => { + it('builds a "minLon,minLat,maxLon,maxLat" string from map bounds', () => { + const bounds = { + getWest: () => -1.5, + getSouth: () => 2.25, + getEast: () => 1.5, + getNorth: () => 3.75, + } + + expect(osm.getBBoxString(bounds)).toBe('-1.5,2.25,1.5,3.75') + }) + }) +}) diff --git a/src/api/project.test.tsx b/src/api/project.test.tsx new file mode 100644 index 000000000..e201ba82a --- /dev/null +++ b/src/api/project.test.tsx @@ -0,0 +1,382 @@ +import { waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { renderHookWithClient } from '@/test/queryClient' +import type { Challenge } from '@/types/Challenge' +import type { Project } from '@/types/Project' + +const { apiRequestMock } = vi.hoisted(() => ({ + apiRequestMock: { + get: vi.fn(), + post: vi.fn(), + put: vi.fn(), + delete: vi.fn(), + }, +})) + +vi.mock('@/api/client', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, apiRequest: apiRequestMock } +}) + +import { project } from './project' + +function makeProject(props: Partial & { id: number }): Project { + return { name: `project-${props.id}`, enabled: true, ...props } as unknown as Project +} + +function makeChallenge(props: Partial & { id: number }): Challenge { + return { name: `challenge-${props.id}`, ...props } as unknown as Challenge +} + +describe('project', () => { + beforeEach(() => { + apiRequestMock.get.mockReset() + apiRequestMock.post.mockReset() + apiRequestMock.put.mockReset() + apiRequestMock.delete.mockReset() + }) + + describe('featuredProjects', () => { + it('fetches featured projects with default params and caches each by id', async () => { + const projects = [makeProject({ id: 1 }), makeProject({ id: 2 })] + apiRequestMock.get.mockReturnValue({ json: () => Promise.resolve(projects) }) + + const { result, queryClient } = renderHookWithClient(() => project.featuredProjects()) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(apiRequestMock.get).toHaveBeenCalledWith('api/v2/projects/featured', { + searchParams: { limit: 10, onlyEnabled: true, page: 0 }, + }) + expect(result.current.data).toEqual(projects) + expect(queryClient.getQueryData(['project', 1])).toEqual(projects[0]) + expect(queryClient.getQueryData(['project', 2])).toEqual(projects[1]) + }) + + it('forwards custom limit/onlyEnabled/page params', async () => { + apiRequestMock.get.mockReturnValue({ json: () => Promise.resolve([]) }) + + const { result } = renderHookWithClient(() => + project.featuredProjects({ limit: 5, onlyEnabled: false, page: 2 }) + ) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(apiRequestMock.get).toHaveBeenCalledWith('api/v2/projects/featured', { + searchParams: { limit: 5, onlyEnabled: false, page: 2 }, + }) + }) + }) + + describe('getProject', () => { + it('fetches a project by id', async () => { + const singleProject = makeProject({ id: 42 }) + apiRequestMock.get.mockReturnValue({ json: () => Promise.resolve(singleProject) }) + + const { result } = renderHookWithClient(() => project.getProject(42)) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(apiRequestMock.get).toHaveBeenCalledWith('api/v2/project/42') + expect(result.current.data).toEqual(singleProject) + }) + + it('does not fetch when projectId is undefined', () => { + const { result } = renderHookWithClient(() => project.getProject(undefined)) + + expect(result.current.fetchStatus).toBe('idle') + expect(apiRequestMock.get).not.toHaveBeenCalled() + }) + }) + + describe('getProjectOptions', () => { + it('builds query options that fetch a project by id', async () => { + const singleProject = makeProject({ id: 7 }) + apiRequestMock.get.mockReturnValue({ json: () => Promise.resolve(singleProject) }) + + const options = project.getProjectOptions(7) + + expect(options.queryKey).toEqual(['project', 7]) + await expect(options.queryFn?.({} as never)).resolves.toEqual(singleProject) + expect(apiRequestMock.get).toHaveBeenCalledWith('api/v2/project/7') + }) + }) + + describe('getManagedProjects', () => { + it('fetches managed projects with default params and caches each by id', async () => { + const projects = [makeProject({ id: 1 }), makeProject({ id: 2 })] + apiRequestMock.get.mockReturnValue({ json: () => Promise.resolve(projects) }) + + const { result, queryClient } = renderHookWithClient(() => project.getManagedProjects()) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(apiRequestMock.get).toHaveBeenCalledWith('api/v2/projects/managed', { + searchParams: { + limit: 50, + page: 0, + onlyEnabled: false, + onlyOwned: false, + searchString: '', + }, + }) + expect(queryClient.getQueryData(['project', 1])).toEqual(projects[0]) + }) + + it('forwards custom params', async () => { + apiRequestMock.get.mockReturnValue({ json: () => Promise.resolve([]) }) + + const { result } = renderHookWithClient(() => + project.getManagedProjects({ + limit: 20, + page: 1, + onlyEnabled: true, + onlyOwned: true, + searchString: 'road', + }) + ) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(apiRequestMock.get).toHaveBeenCalledWith('api/v2/projects/managed', { + searchParams: { + limit: 20, + page: 1, + onlyEnabled: true, + onlyOwned: true, + searchString: 'road', + }, + }) + }) + }) + + describe('getProjectChallengesOptions', () => { + it('builds query options that fetch challenges for a project with default paging', async () => { + const challenges = [makeChallenge({ id: 1 })] + apiRequestMock.get.mockReturnValue({ json: () => Promise.resolve(challenges) }) + + const options = project.getProjectChallengesOptions(9) + + expect(options.queryKey).toEqual(['project', 'challenges', 9, { limit: 100, page: 0 }]) + await expect(options.queryFn?.({} as never)).resolves.toEqual(challenges) + expect(apiRequestMock.get).toHaveBeenCalledWith('api/v2/project/9/challenges', { + searchParams: { limit: 100, page: 0 }, + }) + }) + }) + + describe('getProjectChallenges', () => { + it('fetches challenges for a project and caches each by id', async () => { + const challenges = [makeChallenge({ id: 11 }), makeChallenge({ id: 12 })] + apiRequestMock.get.mockReturnValue({ json: () => Promise.resolve(challenges) }) + + const { result, queryClient } = renderHookWithClient(() => + project.getProjectChallenges(9, 25, 1) + ) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(apiRequestMock.get).toHaveBeenCalledWith('api/v2/project/9/challenges', { + searchParams: { limit: 25, page: 1 }, + }) + expect(result.current.data).toEqual(challenges) + expect(queryClient.getQueryData(['challenge', 11])).toEqual(challenges[0]) + expect(queryClient.getQueryData(['challenge', 12])).toEqual(challenges[1]) + }) + + it('does not fetch when projectId is undefined', () => { + const { result } = renderHookWithClient(() => project.getProjectChallenges(undefined)) + + expect(result.current.fetchStatus).toBe('idle') + expect(apiRequestMock.get).not.toHaveBeenCalled() + }) + }) + + describe('searchProjects', () => { + it('does not search when the search string is empty', () => { + const { result } = renderHookWithClient(() => project.searchProjects()) + + expect(result.current.fetchStatus).toBe('idle') + expect(apiRequestMock.get).not.toHaveBeenCalled() + }) + + it('searches and caches results by id when given a search string', async () => { + const projects = [makeProject({ id: 3 })] + apiRequestMock.get.mockReturnValue({ json: () => Promise.resolve(projects) }) + + const { result, queryClient } = renderHookWithClient(() => + project.searchProjects({ search: 'park' }) + ) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(apiRequestMock.get).toHaveBeenCalledWith('api/v2/projects/search', { + searchParams: { search: 'park' }, + }) + expect(queryClient.getQueryData(['project', 3])).toEqual(projects[0]) + }) + }) + + describe('exportProjectTasksCsv', () => { + it('fetches CSV text and triggers a client-side download with the given filename', async () => { + apiRequestMock.get.mockReturnValue({ text: () => Promise.resolve('id,name\n1,foo') }) + const createObjectURLSpy = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:fake-url') + const revokeObjectURLSpy = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {}) + const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {}) + + await project.exportProjectTasksCsv(3, 'my-export.csv') + + expect(apiRequestMock.get).toHaveBeenCalledWith('api/v2/project/3/tasks/extract') + expect(createObjectURLSpy).toHaveBeenCalledTimes(1) + const [blobArg] = createObjectURLSpy.mock.calls[0] + expect(blobArg).toBeInstanceOf(Blob) + expect(clickSpy).toHaveBeenCalledTimes(1) + expect(revokeObjectURLSpy).toHaveBeenCalledWith('blob:fake-url') + + createObjectURLSpy.mockRestore() + revokeObjectURLSpy.mockRestore() + clickSpy.mockRestore() + }) + + it('defaults the filename to project--tasks.csv when none is given', async () => { + apiRequestMock.get.mockReturnValue({ text: () => Promise.resolve('id,name') }) + vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:fake-url') + vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {}) + const anchors: HTMLAnchorElement[] = [] + const originalCreateElement = document.createElement.bind(document) + vi.spyOn(document, 'createElement').mockImplementation((tag: string) => { + const el = originalCreateElement(tag) + if (tag === 'a') { + vi.spyOn(el as HTMLAnchorElement, 'click').mockImplementation(() => {}) + anchors.push(el as HTMLAnchorElement) + } + return el + }) + + await project.exportProjectTasksCsv(5) + + expect(anchors[0].download).toBe('project-5-tasks.csv') + + vi.restoreAllMocks() + }) + }) + + describe('useCreateProject', () => { + it('posts the new project and seeds the cache, prepending to any managed list', async () => { + const newProject = makeProject({ id: 100, name: 'brand-new' }) + apiRequestMock.post.mockReturnValue({ json: () => Promise.resolve(newProject) }) + + const { result, queryClient } = renderHookWithClient(() => project.useCreateProject()) + queryClient.setQueryData(['project', 'managed', { limit: 50 }], [makeProject({ id: 1 })]) + + result.current.mutate({ name: 'brand-new' }) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(apiRequestMock.post).toHaveBeenCalledWith('api/v2/project', { + json: { name: 'brand-new' }, + }) + expect(queryClient.getQueryData(['project', 100])).toEqual(newProject) + expect(queryClient.getQueryData(['project', 'managed', { limit: 50 }])).toEqual([ + newProject, + makeProject({ id: 1 }), + ]) + }) + + it('sets a fresh managed list when none was cached yet', async () => { + const newProject = makeProject({ id: 101 }) + apiRequestMock.post.mockReturnValue({ json: () => Promise.resolve(newProject) }) + + const { result, queryClient } = renderHookWithClient(() => project.useCreateProject()) + + result.current.mutate({ name: 'brand-new' }) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(queryClient.getQueriesData({ queryKey: ['project', 'managed'] })).toEqual([]) + }) + }) + + describe('useUpdateProject', () => { + it('puts the update merged with the id and updates the cache', async () => { + const updated = makeProject({ id: 5, name: 'renamed' }) + apiRequestMock.put.mockReturnValue({ json: () => Promise.resolve(updated) }) + + const { result, queryClient } = renderHookWithClient(() => project.useUpdateProject()) + queryClient.setQueryData( + ['project', 'managed', { limit: 50 }], + [makeProject({ id: 5, name: 'old' })] + ) + + result.current.mutate({ projectId: 5, updates: { name: 'renamed' } }) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(apiRequestMock.put).toHaveBeenCalledWith('api/v2/project/5', { + json: { id: 5, name: 'renamed' }, + }) + expect(queryClient.getQueryData(['project', 5])).toEqual(updated) + expect(queryClient.getQueryData(['project', 'managed', { limit: 50 }])).toEqual([updated]) + }) + }) + + describe('useDeleteProject', () => { + it('deletes without a searchParams when immediate is not set, and updates the cache', async () => { + apiRequestMock.delete.mockReturnValue(Promise.resolve(undefined)) + + const { result, queryClient } = renderHookWithClient(() => project.useDeleteProject()) + queryClient.setQueryData( + ['project', 'managed', { limit: 50 }], + [makeProject({ id: 5 }), makeProject({ id: 6 })] + ) + queryClient.setQueryData(['project', 5], makeProject({ id: 5 })) + + result.current.mutate({ projectId: 5 }) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(apiRequestMock.delete).toHaveBeenCalledWith('api/v2/project/5', { + searchParams: undefined, + }) + expect(queryClient.getQueryData(['project', 5])).toBeUndefined() + expect(queryClient.getQueryData(['project', 'managed', { limit: 50 }])).toEqual([ + makeProject({ id: 6 }), + ]) + }) + + it('passes searchParams immediate=true when immediate is requested', async () => { + apiRequestMock.delete.mockReturnValue(Promise.resolve(undefined)) + + const { result } = renderHookWithClient(() => project.useDeleteProject()) + + result.current.mutate({ projectId: 5, immediate: true }) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(apiRequestMock.delete).toHaveBeenCalledWith('api/v2/project/5', { + searchParams: { immediate: 'true' }, + }) + }) + }) + + describe('getProjectStats', () => { + it('fetches project stats data', async () => { + const stats = { id: 3, name: 'proj', actions: { total: 10, available: 4 } } + apiRequestMock.get.mockReturnValue({ json: () => Promise.resolve(stats) }) + + const { result } = renderHookWithClient(() => project.getProjectStats(3)) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(apiRequestMock.get).toHaveBeenCalledWith('api/v2/data/project/3') + expect(result.current.data).toEqual(stats) + }) + + it('does not fetch when projectId is undefined', () => { + const { result } = renderHookWithClient(() => project.getProjectStats(undefined)) + + expect(result.current.fetchStatus).toBe('idle') + expect(apiRequestMock.get).not.toHaveBeenCalled() + }) + }) +}) diff --git a/src/api/project.ts b/src/api/project.ts index 40fc49457..9ea0548a5 100644 --- a/src/api/project.ts +++ b/src/api/project.ts @@ -1,7 +1,7 @@ import { queryOptions, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import type { Challenge } from '@/types/Challenge' import type { Project, ProjectGetResponse } from '@/types/Project' -import { apiRequest } from './' +import { apiRequest } from './client' export const project = { featuredProjects: ({ diff --git a/src/api/search.test.tsx b/src/api/search.test.tsx new file mode 100644 index 000000000..444a064e7 --- /dev/null +++ b/src/api/search.test.tsx @@ -0,0 +1,101 @@ +import { waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { renderHookWithClient } from '@/test/queryClient' + +const { apiRequestMock } = vi.hoisted(() => ({ + apiRequestMock: { + get: vi.fn(), + post: vi.fn(), + put: vi.fn(), + delete: vi.fn(), + }, +})) + +vi.mock('@/api/client', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, apiRequest: apiRequestMock } +}) + +import type { SearchByIdResult, SearchResult } from './search' +import { search } from './search' + +describe('search', () => { + beforeEach(() => { + apiRequestMock.get.mockReset() + }) + + describe('unifiedSearch', () => { + it('does not search when the query string is empty', () => { + const { result } = renderHookWithClient(() => search.unifiedSearch({ q: '' })) + + expect(result.current.fetchStatus).toBe('idle') + expect(apiRequestMock.get).not.toHaveBeenCalled() + }) + + it('searches with the default limit when only q is given', async () => { + const data: SearchResult = { + projects: [{ id: 1, name: 'proj' }], + challenges: [{ id: 2, name: 'chal' }], + tasks: [{ id: 3, name: 'task', status: 0, parent: 2, challengeName: 'chal' }], + } + apiRequestMock.get.mockReturnValue({ json: () => Promise.resolve(data) }) + + const { result } = renderHookWithClient(() => search.unifiedSearch({ q: 'road' })) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(apiRequestMock.get).toHaveBeenCalledWith('api/v2/search', { + searchParams: { q: 'road', limit: 10 }, + }) + expect(result.current.data).toEqual(data) + }) + + it('forwards a custom limit', async () => { + apiRequestMock.get.mockReturnValue({ + json: () => Promise.resolve({ projects: [], challenges: [], tasks: [] }), + }) + + const { result } = renderHookWithClient(() => search.unifiedSearch({ q: 'road', limit: 25 })) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(apiRequestMock.get).toHaveBeenCalledWith('api/v2/search', { + searchParams: { q: 'road', limit: 25 }, + }) + }) + }) + + describe('searchById', () => { + it('does not search when id is not greater than zero', () => { + const { result } = renderHookWithClient(() => search.searchById({ id: 0 })) + + expect(result.current.fetchStatus).toBe('idle') + expect(apiRequestMock.get).not.toHaveBeenCalled() + }) + + it('does not search for a negative id', () => { + const { result } = renderHookWithClient(() => search.searchById({ id: -1 })) + + expect(result.current.fetchStatus).toBe('idle') + expect(apiRequestMock.get).not.toHaveBeenCalled() + }) + + it('searches by id when id is greater than zero', async () => { + const data: SearchByIdResult = { + project: { id: 1, name: 'proj' }, + challenge: null, + task: null, + } + apiRequestMock.get.mockReturnValue({ json: () => Promise.resolve(data) }) + + const { result } = renderHookWithClient(() => search.searchById({ id: 7 })) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(apiRequestMock.get).toHaveBeenCalledWith('api/v2/search/byId', { + searchParams: { id: 7 }, + }) + expect(result.current.data).toEqual(data) + }) + }) +}) diff --git a/src/api/search.ts b/src/api/search.ts index e63f1f58b..6382f6b26 100644 --- a/src/api/search.ts +++ b/src/api/search.ts @@ -1,5 +1,5 @@ import { queryOptions, useQuery } from '@tanstack/react-query' -import { apiRequest } from './' +import { apiRequest } from './client' export interface SearchResult { projects: Array<{ diff --git a/src/api/service/index.test.tsx b/src/api/service/index.test.tsx new file mode 100644 index 000000000..26e6d8264 --- /dev/null +++ b/src/api/service/index.test.tsx @@ -0,0 +1,44 @@ +import { waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { renderHookWithClient } from '@/test/queryClient' + +const { apiRequestMock } = vi.hoisted(() => ({ + apiRequestMock: { + get: vi.fn(), + post: vi.fn(), + put: vi.fn(), + delete: vi.fn(), + }, +})) + +vi.mock('@/api/client', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, apiRequest: apiRequestMock } +}) + +import { service } from './index' +import type { ServiceInfo } from './info' +import { serviceApi } from './info' + +describe('service', () => { + beforeEach(() => { + apiRequestMock.get.mockReset() + }) + + it('re-exports every function from serviceApi', () => { + expect(Object.keys(service).sort()).toEqual(Object.keys(serviceApi).sort()) + expect(service.info).toBe(serviceApi.info) + }) + + it('info fetches the service info via the wired-through function', async () => { + const info = { version: '1.2.3' } as unknown as ServiceInfo + apiRequestMock.get.mockReturnValue({ json: () => Promise.resolve(info) }) + + const { result } = renderHookWithClient(() => service.info()) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(apiRequestMock.get).toHaveBeenCalledWith('api/v2/service/info') + expect(result.current.data).toEqual(info) + }) +}) diff --git a/src/api/service/info.test.tsx b/src/api/service/info.test.tsx new file mode 100644 index 000000000..99fc480ee --- /dev/null +++ b/src/api/service/info.test.tsx @@ -0,0 +1,51 @@ +import { waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { renderHookWithClient } from '@/test/queryClient' + +const { apiRequestMock } = vi.hoisted(() => ({ + apiRequestMock: { + get: vi.fn(), + post: vi.fn(), + put: vi.fn(), + delete: vi.fn(), + }, +})) + +vi.mock('@/api/client', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, apiRequest: apiRequestMock } +}) + +import type { ServiceInfo } from './info' +import { serviceApi } from './info' + +describe('serviceApi', () => { + beforeEach(() => { + apiRequestMock.get.mockReset() + }) + + describe('info', () => { + it('fetches the service info from the expected endpoint', async () => { + const info = { version: '1.2.3' } as unknown as ServiceInfo + apiRequestMock.get.mockReturnValue({ json: () => Promise.resolve(info) }) + + const { result } = renderHookWithClient(() => serviceApi.info()) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(apiRequestMock.get).toHaveBeenCalledWith('api/v2/service/info') + expect(result.current.data).toEqual(info) + }) + + it('uses the ["service", "info"] query key', async () => { + const info = { version: '1.2.3' } as unknown as ServiceInfo + apiRequestMock.get.mockReturnValue({ json: () => Promise.resolve(info) }) + + const { result, queryClient } = renderHookWithClient(() => serviceApi.info()) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(queryClient.getQueryData(['service', 'info'])).toEqual(info) + }) + }) +}) diff --git a/src/api/service/info.ts b/src/api/service/info.ts index 154a7e3ac..17f43435b 100644 --- a/src/api/service/info.ts +++ b/src/api/service/info.ts @@ -1,6 +1,6 @@ import { queryOptions, useQuery } from '@tanstack/react-query' import type { components } from '@/types/openApiTypes' -import { apiRequest } from '../' +import { apiRequest } from '../client' export type ServiceInfo = components['schemas']['org.maproulette.models.service.info.ServiceInfo'] diff --git a/src/api/task/bulk.test.tsx b/src/api/task/bulk.test.tsx new file mode 100644 index 000000000..9dca8164f --- /dev/null +++ b/src/api/task/bulk.test.tsx @@ -0,0 +1,132 @@ +import { waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { renderHookWithClient } from '@/test/queryClient' + +const { apiRequestMock } = vi.hoisted(() => ({ + apiRequestMock: { + get: vi.fn(), + post: vi.fn(), + put: vi.fn(), + delete: vi.fn(), + }, +})) + +vi.mock('@/api/client', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, apiRequest: apiRequestMock } +}) + +import type { BulkDeleteResult, BulkReassignResult } from './bulk' +import { taskBulk } from './bulk' + +describe('taskBulk', () => { + beforeEach(() => { + apiRequestMock.get.mockReset() + apiRequestMock.post.mockReset() + apiRequestMock.put.mockReset() + apiRequestMock.delete.mockReset() + }) + + it('useBulkUpdateStatus PUTs each task id to the status endpoint and invalidates task/challenge', async () => { + apiRequestMock.put.mockReturnValue({ text: () => Promise.resolve('') }) + const { result, queryClient } = renderHookWithClient(() => taskBulk.useBulkUpdateStatus()) + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries') + + result.current.mutate({ taskIds: [1, 2, 3], status: 2 }) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(apiRequestMock.put).toHaveBeenCalledWith('api/v2/task/1/2') + expect(apiRequestMock.put).toHaveBeenCalledWith('api/v2/task/2/2') + expect(apiRequestMock.put).toHaveBeenCalledWith('api/v2/task/3/2') + expect(apiRequestMock.put).toHaveBeenCalledTimes(3) + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['task'] }) + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['challenge'] }) + }) + + it('useBulkAddTags joins tags and GETs the tags/update endpoint for each task id', async () => { + apiRequestMock.get.mockReturnValue(Promise.resolve(undefined)) + const { result, queryClient } = renderHookWithClient(() => taskBulk.useBulkAddTags()) + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries') + + result.current.mutate({ taskIds: [4, 5], tags: ['foo', 'bar'] }) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(apiRequestMock.get).toHaveBeenCalledWith('api/v2/task/4/tags/update', { + searchParams: { tags: 'foo,bar' }, + }) + expect(apiRequestMock.get).toHaveBeenCalledWith('api/v2/task/5/tags/update', { + searchParams: { tags: 'foo,bar' }, + }) + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['task'] }) + }) + + it('useBulkDelete DELETEs with the taskIds body and invalidates task/challenge', async () => { + const response: BulkDeleteResult = { requested: 2, deleted: 2, denied: [] } + apiRequestMock.delete.mockReturnValue({ json: () => Promise.resolve(response) }) + const { result, queryClient } = renderHookWithClient(() => taskBulk.useBulkDelete()) + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries') + + result.current.mutate([10, 11]) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(apiRequestMock.delete).toHaveBeenCalledWith('api/v2/tasks', { + json: { taskIds: [10, 11] }, + }) + expect(result.current.data).toEqual(response) + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['task'] }) + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['challenge'] }) + }) + + it('useBulkArchive PUTs taskIds and archived flag and invalidates task/challenge', async () => { + apiRequestMock.put.mockReturnValue({ text: () => Promise.resolve('') }) + const { result, queryClient } = renderHookWithClient(() => taskBulk.useBulkArchive()) + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries') + + result.current.mutate({ taskIds: [7], archived: true }) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(apiRequestMock.put).toHaveBeenCalledWith('api/v2/tasks/archive', { + json: { taskIds: [7], archived: true }, + }) + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['task'] }) + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['challenge'] }) + }) + + it('useBulkReassign PUTs taskIds and userId, returns the parsed result, and invalidates only task', async () => { + const response: BulkReassignResult = { requested: 1, updated: 1 } + apiRequestMock.put.mockReturnValue({ json: () => Promise.resolve(response) }) + const { result, queryClient } = renderHookWithClient(() => taskBulk.useBulkReassign()) + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries') + + result.current.mutate({ taskIds: [8], userId: 99 }) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(apiRequestMock.put).toHaveBeenCalledWith('api/v2/tasks/reassign', { + json: { taskIds: [8], userId: 99 }, + }) + expect(result.current.data).toEqual(response) + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['task'] }) + expect(invalidateSpy).not.toHaveBeenCalledWith({ queryKey: ['challenge'] }) + }) + + it('useBulkClearLock POSTs the taskIds array and invalidates task/challenge', async () => { + apiRequestMock.post.mockReturnValue({ json: () => Promise.resolve(undefined) }) + const { result, queryClient } = renderHookWithClient(() => taskBulk.useBulkClearLock()) + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries') + + result.current.mutate([12, 13]) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(apiRequestMock.post).toHaveBeenCalledWith('api/v2/task/bundle/unlock', { + json: [12, 13], + }) + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['task'] }) + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['challenge'] }) + }) +}) diff --git a/src/api/task/bulk.ts b/src/api/task/bulk.ts index 8efee0aec..ea21cf12e 100644 --- a/src/api/task/bulk.ts +++ b/src/api/task/bulk.ts @@ -1,5 +1,5 @@ import { useMutation, useQueryClient } from '@tanstack/react-query' -import { apiRequest } from '../' +import { apiRequest } from '../client' export interface BulkDeleteResult { requested: number diff --git a/src/api/task/comments.test.tsx b/src/api/task/comments.test.tsx new file mode 100644 index 000000000..c154a28e0 --- /dev/null +++ b/src/api/task/comments.test.tsx @@ -0,0 +1,159 @@ +import { waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { renderHookWithClient } from '@/test/queryClient' +import type { TaskGetResponse } from '@/types/Task' + +const { apiRequestMock } = vi.hoisted(() => ({ + apiRequestMock: { + get: vi.fn(), + post: vi.fn(), + put: vi.fn(), + delete: vi.fn(), + }, +})) + +vi.mock('@/api/client', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, apiRequest: apiRequestMock } +}) + +import type { Comment } from '@/types/Comment' +import { taskComments } from './comments' + +function makeComment(props: Partial = {}): Comment { + return { id: 1, comment: 'hello', ...props } as unknown as Comment +} + +describe('taskComments', () => { + beforeEach(() => { + apiRequestMock.get.mockReset() + apiRequestMock.post.mockReset() + }) + + it('searchTaskComments fetches with q/limit search params and defaults limit to 10', async () => { + const comments = [makeComment({ id: 1 })] + apiRequestMock.get.mockReturnValue({ json: () => Promise.resolve(comments) }) + + const { result } = renderHookWithClient(() => taskComments.searchTaskComments({ q: 'foo' })) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(apiRequestMock.get).toHaveBeenCalledWith('api/v2/comments/search', { + searchParams: { q: 'foo', limit: 10 }, + }) + expect(result.current.data).toEqual(comments) + }) + + it('searchTaskComments does not fetch when enabled is false', () => { + const { result } = renderHookWithClient(() => + taskComments.searchTaskComments({ q: 'foo', enabled: false }) + ) + + expect(result.current.fetchStatus).toBe('idle') + expect(apiRequestMock.get).not.toHaveBeenCalled() + }) + + it('getTaskComments fetches comments for a task id', async () => { + const comments = [makeComment({ id: 2 })] + apiRequestMock.get.mockReturnValue({ json: () => Promise.resolve(comments) }) + + const { result } = renderHookWithClient(() => taskComments.getTaskComments(42)) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(apiRequestMock.get).toHaveBeenCalledWith('api/v2/task/42/comments') + expect(result.current.data).toEqual(comments) + }) + + it('getTaskComments does not fetch when taskId is falsy', () => { + const { result } = renderHookWithClient(() => taskComments.getTaskComments(0)) + + expect(result.current.fetchStatus).toBe('idle') + expect(apiRequestMock.get).not.toHaveBeenCalled() + }) + + it('getTaskHistory fetches history for a task id', async () => { + const history = [{ taskId: 42, timestamp: 't', actionType: 1, user: null }] + apiRequestMock.get.mockReturnValue({ json: () => Promise.resolve(history) }) + + const { result } = renderHookWithClient(() => taskComments.getTaskHistory(42)) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(apiRequestMock.get).toHaveBeenCalledWith('api/v2/task/42/history') + expect(result.current.data).toEqual(history) + }) + + it('getTaskHistory does not fetch when taskId is falsy', () => { + const { result } = renderHookWithClient(() => taskComments.getTaskHistory(0)) + + expect(result.current.fetchStatus).toBe('idle') + expect(apiRequestMock.get).not.toHaveBeenCalled() + }) + + it('useAddTaskComment POSTs the comment, appends to cached comments, and invalidates history', async () => { + const newComment = makeComment({ id: 99, comment: 'a new comment' }) + apiRequestMock.post.mockReturnValue({ json: () => Promise.resolve(newComment) }) + + const { result, queryClient } = renderHookWithClient(() => taskComments.useAddTaskComment()) + queryClient.setQueryData(['task', 'comments', 5], [makeComment({ id: 1 })]) + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries') + + result.current.mutate({ taskId: 5, commentText: 'a new comment' }) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(apiRequestMock.post).toHaveBeenCalledWith('api/v2/task/5/comment', { + json: { comment: 'a new comment' }, + }) + expect(queryClient.getQueryData(['task', 'comments', 5])).toEqual([ + makeComment({ id: 1 }), + newComment, + ]) + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['task', 'history', 5] }) + }) + + it('useAddTaskComment seeds the comments cache from empty when nothing was cached yet', async () => { + const newComment = makeComment({ id: 100 }) + apiRequestMock.post.mockReturnValue({ json: () => Promise.resolve(newComment) }) + + const { result, queryClient } = renderHookWithClient(() => taskComments.useAddTaskComment()) + + result.current.mutate({ taskId: 6, commentText: 'first comment' }) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(queryClient.getQueryData(['task', 'comments', 6])).toEqual([newComment]) + }) + + it('useAddTaskComment invalidates the parent challenge activity when the task has a parent cached', async () => { + const newComment = makeComment({ id: 101 }) + apiRequestMock.post.mockReturnValue({ json: () => Promise.resolve(newComment) }) + + const { result, queryClient } = renderHookWithClient(() => taskComments.useAddTaskComment()) + queryClient.setQueryData(['task', 7], { parent: 55 } as unknown as TaskGetResponse) + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries') + + result.current.mutate({ taskId: 7, commentText: 'hi' }) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['challenge', 'activity', 55] }) + }) + + it('useAddTaskComment does not invalidate any challenge activity when the task has no cached parent', async () => { + const newComment = makeComment({ id: 102 }) + apiRequestMock.post.mockReturnValue({ json: () => Promise.resolve(newComment) }) + + const { result, queryClient } = renderHookWithClient(() => taskComments.useAddTaskComment()) + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries') + + result.current.mutate({ taskId: 8, commentText: 'hi' }) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(invalidateSpy).not.toHaveBeenCalledWith( + expect.objectContaining({ queryKey: expect.arrayContaining(['challenge', 'activity']) }) + ) + }) +}) diff --git a/src/api/task/comments.ts b/src/api/task/comments.ts index 2a0592a78..8d20c3701 100644 --- a/src/api/task/comments.ts +++ b/src/api/task/comments.ts @@ -1,7 +1,7 @@ import { queryOptions, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import type { Comment } from '@/types/Comment' import type { TaskGetResponse, TaskHistoryAction } from '@/types/Task' -import { apiRequest } from '../' +import { apiRequest } from '../client' export const taskComments = { searchTaskComments: ({ diff --git a/src/api/task/index.test.tsx b/src/api/task/index.test.tsx new file mode 100644 index 000000000..60890576d --- /dev/null +++ b/src/api/task/index.test.tsx @@ -0,0 +1,115 @@ +import { waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { renderHookWithClient } from '@/test/queryClient' + +const { apiRequestMock } = vi.hoisted(() => ({ + apiRequestMock: { + get: vi.fn(), + post: vi.fn(), + put: vi.fn(), + delete: vi.fn(), + }, +})) + +vi.mock('@/api/client', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, apiRequest: apiRequestMock } +}) + +import { task } from './index' + +const EXPECTED_TASK_KEYS = [ + 'searchTasks', + 'startTask', + 'getTaskOptions', + 'getTask', + 'useLockTask', + 'useUnlockTask', + 'useSkipTask', + 'updateTask', + 'useUpdateTask', + 'useUpdateTaskStatus', + 'getTasks', + 'getTaskMarkers', + 'getTasksInBounds', + 'getTasksInBoundingBox', + 'searchTaskComments', + 'getTaskComments', + 'getTaskHistory', + 'useAddTaskComment', + 'getTaskTags', + 'searchKeywords', + 'useUpdateTaskTags', + 'useBulkUpdateStatus', + 'useBulkAddTags', + 'useBulkDelete', + 'useBulkArchive', + 'useBulkReassign', + 'useBulkClearLock', +].sort() + +describe('task', () => { + beforeEach(() => { + apiRequestMock.get.mockReset() + apiRequestMock.post.mockReset() + apiRequestMock.put.mockReset() + apiRequestMock.delete.mockReset() + }) + + it('merges every sub-module member onto a single object, and only those members', () => { + expect(Object.keys(task).sort()).toEqual(EXPECTED_TASK_KEYS) + for (const key of EXPECTED_TASK_KEYS) { + expect(typeof task[key as keyof typeof task]).toBe('function') + } + }) + + it('wires getTask (from taskSingle) through to the single-task endpoint', async () => { + apiRequestMock.get.mockReturnValue({ json: () => Promise.resolve({ id: 5 }) }) + + const { result } = renderHookWithClient(() => task.getTask(5)) + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(apiRequestMock.get).toHaveBeenCalledWith('api/v2/task/5?mapillary=false') + }) + + it('wires getTasks (from taskMultiple) through to the batch endpoint', async () => { + apiRequestMock.get.mockReturnValue({ json: () => Promise.resolve([{ id: 1 }, { id: 2 }]) }) + + const { result } = renderHookWithClient(() => task.getTasks([2, 1])) + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(apiRequestMock.get).toHaveBeenCalledWith('api/v2/tasks', { + searchParams: { taskIds: '2,1', mapillary: 'false' }, + }) + }) + + it('wires getTaskComments (from taskComments) through to the comments endpoint', async () => { + apiRequestMock.get.mockReturnValue({ json: () => Promise.resolve([]) }) + + const { result } = renderHookWithClient(() => task.getTaskComments(5)) + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(apiRequestMock.get).toHaveBeenCalledWith('api/v2/task/5/comments') + }) + + it('wires getTaskTags (from taskTags) through to the tags endpoint', async () => { + apiRequestMock.get.mockReturnValue({ json: () => Promise.resolve([]) }) + + const { result } = renderHookWithClient(() => task.getTaskTags(5)) + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(apiRequestMock.get).toHaveBeenCalledWith('api/v2/task/5/tags') + }) + + it('wires useBulkClearLock (from taskBulk) through to the unlock endpoint', async () => { + apiRequestMock.post.mockReturnValue({ json: () => Promise.resolve(undefined) }) + + const { result } = renderHookWithClient(() => task.useBulkClearLock()) + result.current.mutate([1, 2]) + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(apiRequestMock.post).toHaveBeenCalledWith('api/v2/task/bundle/unlock', { + json: [1, 2], + }) + }) +}) diff --git a/src/api/task/multiple.test.tsx b/src/api/task/multiple.test.tsx new file mode 100644 index 000000000..18c7c8fcd --- /dev/null +++ b/src/api/task/multiple.test.tsx @@ -0,0 +1,242 @@ +import { waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createTestQueryClient, renderHookWithClient } from '@/test/queryClient' +import type { + TaskGetResponse, + TaskMarkersParams, + TasksBoundingBoxQuery, + TasksInBoundsParams, +} from '@/types/Task' + +const { apiRequestMock } = vi.hoisted(() => ({ + apiRequestMock: { + get: vi.fn(), + post: vi.fn(), + put: vi.fn(), + delete: vi.fn(), + }, +})) + +vi.mock('@/api/client', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, apiRequest: apiRequestMock } +}) + +import { taskMultiple } from './multiple' + +function makeTask(props: Partial = {}): TaskGetResponse { + return { id: 1, parent: 10, status: 0, ...props } as unknown as TaskGetResponse +} + +describe('taskMultiple', () => { + beforeEach(() => { + apiRequestMock.get.mockReset() + apiRequestMock.put.mockReset() + }) + + describe('getTasks', () => { + it('fetches only the ids missing from the cache and merges them with cached tasks', async () => { + const cachedTask = makeTask({ id: 1 }) + const fetchedTask = makeTask({ id: 2 }) + apiRequestMock.get.mockReturnValue({ json: () => Promise.resolve([fetchedTask]) }) + + const client = createTestQueryClient() + client.setQueryData(['task', 1], cachedTask) + const { result } = renderHookWithClient(() => taskMultiple.getTasks([1, 2]), { client }) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(apiRequestMock.get).toHaveBeenCalledWith('api/v2/tasks', { + searchParams: { taskIds: '2', mapillary: 'false' }, + }) + expect(result.current.data).toEqual([cachedTask, fetchedTask]) + }) + + it('does not call the API when every requested id is already cached', async () => { + const cachedTask = makeTask({ id: 3 }) + + const client = createTestQueryClient() + client.setQueryData(['task', 3], cachedTask) + const { result } = renderHookWithClient(() => taskMultiple.getTasks([3]), { client }) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(apiRequestMock.get).not.toHaveBeenCalled() + expect(result.current.data).toEqual([cachedTask]) + }) + + it('caches each newly-fetched task under its own task/id query key', async () => { + const fetchedTask = makeTask({ id: 4 }) + apiRequestMock.get.mockReturnValue({ json: () => Promise.resolve([fetchedTask]) }) + + const { result, queryClient } = renderHookWithClient(() => taskMultiple.getTasks([4])) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(queryClient.getQueryData(['task', 4])).toEqual(fetchedTask) + }) + + it('sorts the ids for the query key regardless of the order passed in', () => { + apiRequestMock.get.mockReturnValue({ json: () => Promise.resolve([]) }) + const { queryClient } = renderHookWithClient(() => taskMultiple.getTasks([3, 1, 2])) + + expect( + queryClient.getQueryCache().findAll({ queryKey: ['task', 'batch', [1, 2, 3]] }) + ).toHaveLength(1) + }) + + it('does not fetch when given an empty id list', () => { + const { result } = renderHookWithClient(() => taskMultiple.getTasks([])) + + expect(result.current.fetchStatus).toBe('idle') + expect(apiRequestMock.get).not.toHaveBeenCalled() + }) + }) + + describe('getTaskMarkers', () => { + it('fetches task markers with the converted search params', async () => { + const response = { markers: [] } + apiRequestMock.get.mockReturnValue({ json: () => Promise.resolve(response) }) + const params = { cid: 7 } as unknown as TaskMarkersParams + + const { result } = renderHookWithClient(() => taskMultiple.getTaskMarkers(params)) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(apiRequestMock.get).toHaveBeenCalledWith('api/v2/taskMarkers', { + searchParams: { cid: 7 }, + signal: expect.any(AbortSignal), + }) + expect(result.current.data).toEqual(response) + }) + + it('passes undefined search params when params is falsy', async () => { + apiRequestMock.get.mockReturnValue({ json: () => Promise.resolve({ markers: [] }) }) + + const { result } = renderHookWithClient(() => + taskMultiple.getTaskMarkers(null as unknown as TaskMarkersParams) + ) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(apiRequestMock.get).toHaveBeenCalledWith('api/v2/taskMarkers', { + searchParams: undefined, + signal: expect.any(AbortSignal), + }) + }) + }) + + describe('getTasksInBounds', () => { + it('fetches tasks in bounds with converted search params', async () => { + const response = { tasks: [], total: 0 } + apiRequestMock.get.mockReturnValue({ json: () => Promise.resolve(response) }) + const params = { left: 1, bottom: 2, right: 3, top: 4 } as unknown as TasksInBoundsParams + + const { result } = renderHookWithClient(() => taskMultiple.getTasksInBounds(params)) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(apiRequestMock.get).toHaveBeenCalledWith('api/v2/tasks/bounds', { + searchParams: { left: 1, bottom: 2, right: 3, top: 4 }, + signal: expect.any(AbortSignal), + }) + expect(result.current.data).toEqual(response) + }) + + it('is disabled when options.enabled is false', () => { + const params = { left: 1, bottom: 2, right: 3, top: 4 } as unknown as TasksInBoundsParams + + const { result } = renderHookWithClient(() => + taskMultiple.getTasksInBounds(params, { enabled: false }) + ) + + expect(result.current.fetchStatus).toBe('idle') + expect(apiRequestMock.get).not.toHaveBeenCalled() + }) + + it('defaults enabled to true when options are omitted', async () => { + apiRequestMock.get.mockReturnValue({ json: () => Promise.resolve({ tasks: [], total: 0 }) }) + const params = { left: 1, bottom: 2, right: 3, top: 4 } as unknown as TasksInBoundsParams + + const { result } = renderHookWithClient(() => taskMultiple.getTasksInBounds(params)) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + expect(apiRequestMock.get).toHaveBeenCalled() + }) + }) + + describe('getTasksInBoundingBox', () => { + it('PUTs the box coordinates in the path and builds filter search params from the query', async () => { + const response = { tasks: [], total: 0 } + apiRequestMock.put.mockReturnValue({ json: () => Promise.resolve(response) }) + + const query: TasksBoundingBoxQuery = { + left: 1, + bottom: 2, + right: 3, + top: 4, + challengeId: 55, + limit: 25, + page: 0, + sort: 'name', + order: 'ASC', + taskStatuses: [0, 1], + priorities: [0], + reviewStatuses: [-1], + metaReviewStatuses: [], + } + + const { result } = renderHookWithClient(() => taskMultiple.getTasksInBoundingBox(query)) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(apiRequestMock.put).toHaveBeenCalledWith('api/v2/tasks/box/1/2/3/4', { + searchParams: { + limit: 25, + page: 0, + sort: 'name', + order: 'ASC', + includeTotal: true, + excludeLocked: true, + includeGeometries: false, + includeTags: false, + cid: 55, + ca: true, + tStatus: '0,1', + priorities: '0', + trStatus: '-1', + // reviewStatuses includes -1, so metaReviewStatusesForApi adds -1 too + mrStatus: '-1', + }, + json: {}, + signal: expect.any(AbortSignal), + }) + expect(result.current.data).toEqual(response) + }) + + it('is disabled when options.enabled is false', () => { + const query: TasksBoundingBoxQuery = { + left: 1, + bottom: 2, + right: 3, + top: 4, + challengeId: 55, + limit: 25, + page: 0, + sort: 'name', + order: 'ASC', + taskStatuses: [], + priorities: [], + reviewStatuses: [], + metaReviewStatuses: [], + } + + const { result } = renderHookWithClient(() => + taskMultiple.getTasksInBoundingBox(query, { enabled: false }) + ) + + expect(result.current.fetchStatus).toBe('idle') + expect(apiRequestMock.put).not.toHaveBeenCalled() + }) + }) +}) diff --git a/src/api/task/multiple.ts b/src/api/task/multiple.ts index c6ab57201..49999508a 100644 --- a/src/api/task/multiple.ts +++ b/src/api/task/multiple.ts @@ -9,7 +9,7 @@ import type { TasksInBoundsParams, TasksInBoundsResponse, } from '@/types/Task' -import { apiRequest, convertParamsToSearchParams } from '../' +import { apiRequest, convertParamsToSearchParams } from '../client' const tasksBoundingBoxSearchParams = (query: TasksBoundingBoxQuery) => { const mr = metaReviewStatusesForApi(query.reviewStatuses, query.metaReviewStatuses) diff --git a/src/api/task/single.test.tsx b/src/api/task/single.test.tsx new file mode 100644 index 000000000..57bb60356 --- /dev/null +++ b/src/api/task/single.test.tsx @@ -0,0 +1,431 @@ +import { waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { renderHookWithClient } from '@/test/queryClient' +import type { TaskGetResponse } from '@/types/Task' +import type { UserWhoamiResponse } from '@/types/User' + +const { apiRequestMock } = vi.hoisted(() => ({ + apiRequestMock: { + get: vi.fn(), + post: vi.fn(), + put: vi.fn(), + delete: vi.fn(), + }, +})) + +vi.mock('@/api/client', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, apiRequest: apiRequestMock } +}) + +import { taskSingle } from './single' + +function makeTask(props: Partial = {}): TaskGetResponse { + return { id: 1, parent: 10, status: 0, priority: 1, ...props } as unknown as TaskGetResponse +} + +function jsonResponse(data: T) { + return { + json: () => Promise.resolve(data), + headers: { get: () => 'application/json' }, + } +} + +describe('taskSingle', () => { + beforeEach(() => { + apiRequestMock.get.mockReset() + apiRequestMock.post.mockReset() + apiRequestMock.put.mockReset() + apiRequestMock.delete.mockReset() + }) + + describe('searchTasks', () => { + it('fetches with q/limit search params, defaulting limit to 25', async () => { + const results = [{ id: 1, name: 'a', status: 0, parent: 2, challengeName: 'c' }] + apiRequestMock.get.mockReturnValue({ json: () => Promise.resolve(results) }) + + const { result } = renderHookWithClient(() => taskSingle.searchTasks({ q: 'foo' })) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(apiRequestMock.get).toHaveBeenCalledWith('api/v2/tasks/search', { + searchParams: { q: 'foo', limit: 25 }, + }) + expect(result.current.data).toEqual(results) + }) + + it('does not fetch when q is empty', () => { + const { result } = renderHookWithClient(() => taskSingle.searchTasks({ q: '' })) + + expect(result.current.fetchStatus).toBe('idle') + expect(apiRequestMock.get).not.toHaveBeenCalled() + }) + }) + + describe('startTask', () => { + it('fetches the start endpoint for a task id', async () => { + const response = makeTask() + apiRequestMock.get.mockReturnValue({ json: () => Promise.resolve(response) }) + + const { result } = renderHookWithClient(() => taskSingle.startTask(1)) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(apiRequestMock.get).toHaveBeenCalledWith('api/v2/task/1/start') + expect(result.current.data).toEqual(response) + }) + + it('does not fetch when taskId is falsy', () => { + const { result } = renderHookWithClient(() => taskSingle.startTask(0)) + + expect(result.current.fetchStatus).toBe('idle') + expect(apiRequestMock.get).not.toHaveBeenCalled() + }) + }) + + describe('getTask / getTaskOptions', () => { + it('fetches the task with mapillary=false', async () => { + const response = makeTask() + apiRequestMock.get.mockReturnValue({ json: () => Promise.resolve(response) }) + + const { result } = renderHookWithClient(() => taskSingle.getTask(1)) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(apiRequestMock.get).toHaveBeenCalledWith('api/v2/task/1?mapillary=false') + expect(result.current.data).toEqual(response) + }) + + it('does not fetch when taskId is falsy', () => { + const { result } = renderHookWithClient(() => taskSingle.getTask(0)) + + expect(result.current.fetchStatus).toBe('idle') + expect(apiRequestMock.get).not.toHaveBeenCalled() + }) + }) + + describe('useLockTask', () => { + it('GETs the start endpoint, caches the task, invalidates history, and patches the marker with the current user id', async () => { + const lockedTask = makeTask({ id: 20, parent: 30 }) + apiRequestMock.get.mockReturnValue({ json: () => Promise.resolve(lockedTask) }) + + const { result, queryClient } = renderHookWithClient(() => taskSingle.useLockTask()) + queryClient.setQueryData(['user', 'whoami'], { id: 77 } as unknown as UserWhoamiResponse) + queryClient.setQueryData(['challenge', 'taskMarkers', 30], { + markers: [{ id: 20, status: 0 }], + }) + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries') + + result.current.mutate(20) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(apiRequestMock.get).toHaveBeenCalledWith('api/v2/task/20/start') + expect(queryClient.getQueryData(['task', 20])).toEqual(lockedTask) + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['task', 'history', 20] }) + expect( + queryClient.getQueryData<{ markers: { id: number; lockedBy: number }[] }>([ + 'challenge', + 'taskMarkers', + 30, + ])?.markers[0].lockedBy + ).toBe(77) + }) + + it('patches the marker with a null lockedBy when there is no cached current user', async () => { + const lockedTask = makeTask({ id: 21, parent: 31 }) + apiRequestMock.get.mockReturnValue({ json: () => Promise.resolve(lockedTask) }) + + const { result, queryClient } = renderHookWithClient(() => taskSingle.useLockTask()) + queryClient.setQueryData(['challenge', 'taskMarkers', 31], { + markers: [{ id: 21, status: 0 }], + }) + + result.current.mutate(21) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect( + queryClient.getQueryData<{ markers: { id: number; lockedBy: number | null }[] }>([ + 'challenge', + 'taskMarkers', + 31, + ])?.markers[0].lockedBy + ).toBe(null) + }) + + it('does not attempt to patch a marker when the locked task has no parent', async () => { + const lockedTask = makeTask({ id: 22, parent: 0 }) + apiRequestMock.get.mockReturnValue({ json: () => Promise.resolve(lockedTask) }) + + const { result, queryClient } = renderHookWithClient(() => taskSingle.useLockTask()) + + result.current.mutate(22) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(queryClient.getQueryData(['task', 22])).toEqual(lockedTask) + }) + }) + + describe('useUnlockTask', () => { + it('GETs the release endpoint, caches the task, invalidates history, and patches the marker lockedBy to null', async () => { + const unlockedTask = makeTask({ id: 23, parent: 32 }) + apiRequestMock.get.mockReturnValue({ json: () => Promise.resolve(unlockedTask) }) + + const { result, queryClient } = renderHookWithClient(() => taskSingle.useUnlockTask()) + queryClient.setQueryData(['challenge', 'taskMarkers', 32], { + markers: [{ id: 23, lockedBy: 5 }], + }) + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries') + + result.current.mutate(23) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(apiRequestMock.get).toHaveBeenCalledWith('api/v2/task/23/release') + expect(queryClient.getQueryData(['task', 23])).toEqual(unlockedTask) + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['task', 'history', 23] }) + expect( + queryClient.getQueryData<{ markers: { id: number; lockedBy: number | null }[] }>([ + 'challenge', + 'taskMarkers', + 32, + ])?.markers[0].lockedBy + ).toBe(null) + }) + }) + + describe('useSkipTask', () => { + it('POSTs to skip, patches the cached task status to 3, invalidates history, patches the marker, and invalidates aggregates when the status actually changed', async () => { + apiRequestMock.post.mockReturnValue({ text: () => Promise.resolve('') }) + + const { result, queryClient } = renderHookWithClient(() => taskSingle.useSkipTask()) + queryClient.setQueryData(['task', 24], makeTask({ id: 24, parent: 33, status: 0 })) + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries') + + result.current.mutate(24) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(apiRequestMock.post).toHaveBeenCalledWith('api/v2/task/24/skip') + expect(queryClient.getQueryData(['task', 24])).toEqual( + makeTask({ id: 24, parent: 33, status: 3 }) + ) + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['task', 'history', 24] }) + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['challenge', 33] }) + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['challenge', 'stats', 33] }) + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['challenge', 'activity', 33] }) + }) + + it('does not invalidate challenge aggregates when the task was already skipped', async () => { + apiRequestMock.post.mockReturnValue({ text: () => Promise.resolve('') }) + + const { result, queryClient } = renderHookWithClient(() => taskSingle.useSkipTask()) + queryClient.setQueryData(['task', 25], makeTask({ id: 25, parent: 34, status: 3 })) + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries') + + result.current.mutate(25) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(invalidateSpy).not.toHaveBeenCalledWith({ queryKey: ['challenge', 34] }) + }) + + it('does nothing to the cache when the task was not already cached', async () => { + apiRequestMock.post.mockReturnValue({ text: () => Promise.resolve('') }) + + const { result, queryClient } = renderHookWithClient(() => taskSingle.useSkipTask()) + + result.current.mutate(26) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(queryClient.getQueryData(['task', 26])).toBeUndefined() + }) + }) + + describe('updateTask', () => { + it('PUTs the body to the task endpoint and returns the parsed task', async () => { + const body = makeTask({ id: 40 }) + const response = makeTask({ id: 40, status: 1 }) + apiRequestMock.put.mockReturnValue({ json: () => Promise.resolve(response) }) + + const result = await taskSingle.updateTask(40, body) + + expect(apiRequestMock.put).toHaveBeenCalledWith('api/v2/task/40', { json: body }) + expect(result).toEqual(response) + }) + }) + + describe('useUpdateTask', () => { + it('updates the task, caches the result, invalidates history, patches the marker, and invalidates aggregates when status changed', async () => { + const updatedTask = makeTask({ id: 41, parent: 50, status: 2, priority: 3 }) + apiRequestMock.put.mockReturnValue({ json: () => Promise.resolve(updatedTask) }) + + const { result, queryClient } = renderHookWithClient(() => taskSingle.useUpdateTask()) + queryClient.setQueryData(['task', 41], makeTask({ id: 41, parent: 50, status: 0 })) + queryClient.setQueryData(['challenge', 'taskMarkers', 50], { + markers: [{ id: 41, status: 0, priority: 1 }], + }) + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries') + + result.current.mutate({ taskId: 41, body: updatedTask }) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(apiRequestMock.put).toHaveBeenCalledWith('api/v2/task/41', { json: updatedTask }) + expect(queryClient.getQueryData(['task', 41])).toEqual(updatedTask) + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['task', 'history', 41] }) + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['challenge', 50] }) + expect( + queryClient.getQueryData<{ markers: { id: number; status: number; priority: number }[] }>([ + 'challenge', + 'taskMarkers', + 50, + ])?.markers[0] + ).toEqual({ id: 41, status: 2, priority: 3 }) + }) + + it('does not invalidate challenge aggregates when the status is unchanged', async () => { + const updatedTask = makeTask({ id: 42, parent: 51, status: 0 }) + apiRequestMock.put.mockReturnValue({ json: () => Promise.resolve(updatedTask) }) + + const { result, queryClient } = renderHookWithClient(() => taskSingle.useUpdateTask()) + queryClient.setQueryData(['task', 42], makeTask({ id: 42, parent: 51, status: 0 })) + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries') + + result.current.mutate({ taskId: 42, body: updatedTask }) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(invalidateSpy).not.toHaveBeenCalledWith({ queryKey: ['challenge', 51] }) + }) + }) + + describe('useUpdateTaskStatus', () => { + it('PUTs to the status endpoint with no query string when no options are given', async () => { + const response = makeTask({ id: 60, status: 2 }) + apiRequestMock.put.mockReturnValue(jsonResponse(response)) + + const { result, queryClient } = renderHookWithClient(() => taskSingle.useUpdateTaskStatus()) + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries') + + result.current.mutate({ taskId: 60, status: 2 }) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(apiRequestMock.put).toHaveBeenCalledWith('api/v2/task/60/2') + expect(queryClient.getQueryData(['task', 60])).toEqual(response) + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['task', 'history', 60] }) + }) + + it('builds a query string with tags and requestReview when provided', async () => { + const response = makeTask({ id: 61, status: 2 }) + apiRequestMock.put.mockReturnValue(jsonResponse(response)) + + const { result } = renderHookWithClient(() => taskSingle.useUpdateTaskStatus()) + + result.current.mutate({ + taskId: 61, + status: 2, + options: { tags: ['a', 'b'], requestReview: true }, + }) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(apiRequestMock.put).toHaveBeenCalledWith( + 'api/v2/task/61/2?tags=a%2Cb&requestReview=true' + ) + }) + + it('posts a comment separately when options.comment is provided, and invalidates the comments cache', async () => { + const response = makeTask({ id: 62, status: 2 }) + apiRequestMock.put.mockReturnValue(jsonResponse(response)) + apiRequestMock.post.mockReturnValue({ json: () => Promise.resolve({ id: 1 }) }) + + const { result, queryClient } = renderHookWithClient(() => taskSingle.useUpdateTaskStatus()) + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries') + + result.current.mutate({ taskId: 62, status: 2, options: { comment: 'looks good' } }) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(apiRequestMock.post).toHaveBeenCalledWith('api/v2/task/62/comment', { + json: { comment: 'looks good' }, + }) + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['task', 'comments', 62] }) + }) + + it('does not invalidate the comments cache when no comment option is given', async () => { + const response = makeTask({ id: 63, status: 2 }) + apiRequestMock.put.mockReturnValue(jsonResponse(response)) + + const { result, queryClient } = renderHookWithClient(() => taskSingle.useUpdateTaskStatus()) + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries') + + result.current.mutate({ taskId: 63, status: 2 }) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(invalidateSpy).not.toHaveBeenCalledWith({ queryKey: ['task', 'comments', 63] }) + }) + + it('re-fetches the task when the PUT response has no JSON content-type (e.g. 204)', async () => { + const refetched = makeTask({ id: 64, status: 2 }) + apiRequestMock.put.mockReturnValue({ + headers: { get: () => null }, + }) + apiRequestMock.get.mockReturnValue({ json: () => Promise.resolve(refetched) }) + + const { result } = renderHookWithClient(() => taskSingle.useUpdateTaskStatus()) + + result.current.mutate({ taskId: 64, status: 2 }) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(apiRequestMock.get).toHaveBeenCalledWith('api/v2/task/64?mapillary=false') + expect(result.current.data).toEqual(refetched) + }) + + it('patches the marker status and invalidates aggregates when the cached status differs from the new status', async () => { + const response = makeTask({ id: 65, parent: 70, status: 2 }) + apiRequestMock.put.mockReturnValue(jsonResponse(response)) + + const { result, queryClient } = renderHookWithClient(() => taskSingle.useUpdateTaskStatus()) + queryClient.setQueryData(['task', 65], makeTask({ id: 65, parent: 70, status: 0 })) + queryClient.setQueryData(['challenge', 'taskMarkers', 70], { + markers: [{ id: 65, status: 0 }], + }) + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries') + + result.current.mutate({ taskId: 65, status: 2 }) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['challenge', 70] }) + expect( + queryClient.getQueryData<{ markers: { id: number; status: number }[] }>([ + 'challenge', + 'taskMarkers', + 70, + ])?.markers[0].status + ).toBe(2) + }) + + it('does not invalidate aggregates when the cached status matches the new status', async () => { + const response = makeTask({ id: 66, parent: 71, status: 2 }) + apiRequestMock.put.mockReturnValue(jsonResponse(response)) + + const { result, queryClient } = renderHookWithClient(() => taskSingle.useUpdateTaskStatus()) + queryClient.setQueryData(['task', 66], makeTask({ id: 66, parent: 71, status: 2 })) + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries') + + result.current.mutate({ taskId: 66, status: 2 }) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(invalidateSpy).not.toHaveBeenCalledWith({ queryKey: ['challenge', 71] }) + }) + }) +}) diff --git a/src/api/task/single.ts b/src/api/task/single.ts index 6231190d6..5300f76c6 100644 --- a/src/api/task/single.ts +++ b/src/api/task/single.ts @@ -2,7 +2,7 @@ import { queryOptions, useMutation, useQuery, useQueryClient } from '@tanstack/r import { invalidateChallengeAggregates, patchChallengeTaskMarker } from '@/api/challenge/single' import type { TaskGetResponse, TaskStartResponse } from '@/types/Task' import type { UserWhoamiResponse } from '@/types/User' -import { apiRequest } from '../' +import { apiRequest } from '../client' const getCurrentUserId = (queryClient: ReturnType): number | null => { const me = queryClient.getQueryData(['user', 'whoami']) diff --git a/src/api/task/tags.test.tsx b/src/api/task/tags.test.tsx new file mode 100644 index 000000000..5754d6418 --- /dev/null +++ b/src/api/task/tags.test.tsx @@ -0,0 +1,106 @@ +import { waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { renderHookWithClient } from '@/test/queryClient' + +const { apiRequestMock } = vi.hoisted(() => ({ + apiRequestMock: { + get: vi.fn(), + post: vi.fn(), + put: vi.fn(), + delete: vi.fn(), + }, +})) + +vi.mock('@/api/client', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, apiRequest: apiRequestMock } +}) + +import type { Keyword } from './tags' +import { taskTags } from './tags' + +function makeKeyword(props: Partial = {}): Keyword { + return { id: 1, name: 'keyword' as unknown, ...props } as unknown as Keyword +} + +describe('taskTags', () => { + beforeEach(() => { + apiRequestMock.get.mockReset() + }) + + describe('getTaskTags', () => { + it('fetches the tags for a task id', async () => { + const tags = [makeKeyword({ id: 1 })] + apiRequestMock.get.mockReturnValue({ json: () => Promise.resolve(tags) }) + + const { result } = renderHookWithClient(() => taskTags.getTaskTags(5)) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(apiRequestMock.get).toHaveBeenCalledWith('api/v2/task/5/tags') + expect(result.current.data).toEqual(tags) + }) + + it('does not fetch when taskId is falsy', () => { + const { result } = renderHookWithClient(() => taskTags.getTaskTags(0)) + + expect(result.current.fetchStatus).toBe('idle') + expect(apiRequestMock.get).not.toHaveBeenCalled() + }) + }) + + describe('searchKeywords', () => { + it('fetches keywords with prefix, defaulted tagType and limit', async () => { + const keywords = [makeKeyword({ id: 2 })] + apiRequestMock.get.mockReturnValue({ json: () => Promise.resolve(keywords) }) + + const { result } = renderHookWithClient(() => taskTags.searchKeywords('foo')) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(apiRequestMock.get).toHaveBeenCalledWith('api/v2/keywords/find', { + searchParams: { prefix: 'foo', tagType: 'tasks', limit: 10 }, + }) + expect(result.current.data).toEqual(keywords) + }) + + it('passes through an explicit tagType and limit', async () => { + apiRequestMock.get.mockReturnValue({ json: () => Promise.resolve([]) }) + + const { result } = renderHookWithClient(() => taskTags.searchKeywords('bar', 'challenges', 5)) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(apiRequestMock.get).toHaveBeenCalledWith('api/v2/keywords/find', { + searchParams: { prefix: 'bar', tagType: 'challenges', limit: 5 }, + }) + }) + + it('does not fetch when prefix is empty', () => { + const { result } = renderHookWithClient(() => taskTags.searchKeywords('')) + + expect(result.current.fetchStatus).toBe('idle') + expect(apiRequestMock.get).not.toHaveBeenCalled() + }) + }) + + describe('useUpdateTaskTags', () => { + it('GETs the tags/update endpoint with joined tags and invalidates the tags and task caches on success', async () => { + apiRequestMock.get.mockReturnValue(Promise.resolve(undefined)) + + const { result, queryClient } = renderHookWithClient(() => taskTags.useUpdateTaskTags()) + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries') + + result.current.mutate({ taskId: 9, tags: ['x', 'y'] }) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(apiRequestMock.get).toHaveBeenCalledWith('api/v2/task/9/tags/update', { + searchParams: { tags: 'x,y' }, + }) + expect(result.current.data).toEqual(['x', 'y']) + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['task', 9, 'tags'] }) + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['task', 9] }) + }) + }) +}) diff --git a/src/api/task/tags.ts b/src/api/task/tags.ts index acdf9fa4f..481a5f1cd 100644 --- a/src/api/task/tags.ts +++ b/src/api/task/tags.ts @@ -1,6 +1,6 @@ import { queryOptions, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import type { components } from '@/types/openApiTypes' -import { apiRequest } from '../' +import { apiRequest } from '../client' export type Keyword = components['schemas']['Keyword'] diff --git a/src/api/taskBundle/index.test.tsx b/src/api/taskBundle/index.test.tsx new file mode 100644 index 000000000..53ed690b2 --- /dev/null +++ b/src/api/taskBundle/index.test.tsx @@ -0,0 +1,69 @@ +import { waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { renderHookWithClient } from '@/test/queryClient' + +const { apiRequestMock } = vi.hoisted(() => ({ + apiRequestMock: { + get: vi.fn(), + post: vi.fn(), + put: vi.fn(), + delete: vi.fn(), + }, +})) + +vi.mock('@/api/client', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, apiRequest: apiRequestMock } +}) + +import { taskBundle } from './index' +import type { TaskBundleResponse } from './queries' +import { taskBundleQueries } from './queries' + +describe('taskBundle', () => { + beforeEach(() => { + apiRequestMock.get.mockReset() + apiRequestMock.post.mockReset() + apiRequestMock.put.mockReset() + apiRequestMock.delete.mockReset() + }) + + it('re-exports every function from taskBundleQueries', () => { + expect(Object.keys(taskBundle).sort()).toEqual(Object.keys(taskBundleQueries).sort()) + expect(taskBundle.getTaskBundle).toBe(taskBundleQueries.getTaskBundle) + expect(taskBundle.useCreateTaskBundle).toBe(taskBundleQueries.useCreateTaskBundle) + expect(taskBundle.useUpdateTaskBundle).toBe(taskBundleQueries.useUpdateTaskBundle) + expect(taskBundle.useDeleteTaskBundle).toBe(taskBundleQueries.useDeleteTaskBundle) + expect(taskBundle.useUpdateTaskBundleStatus).toBe(taskBundleQueries.useUpdateTaskBundleStatus) + }) + + it('getTaskBundle fetches the bundle via the wired-through function', async () => { + const bundle: TaskBundleResponse = { bundleId: 5, ownerId: 1, taskIds: [1, 2] } + apiRequestMock.post.mockReturnValue({ json: () => Promise.resolve(bundle) }) + + const { result } = renderHookWithClient(() => taskBundle.getTaskBundle(5)) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(apiRequestMock.post).toHaveBeenCalledWith('api/v2/taskBundle/5', { + searchParams: { lockTasks: 'false' }, + }) + expect(result.current.data).toEqual(bundle) + }) + + it('useCreateTaskBundle creates a bundle via the wired-through function', async () => { + const bundle: TaskBundleResponse = { bundleId: 9, ownerId: 1, taskIds: [7] } + apiRequestMock.post.mockReturnValue({ json: () => Promise.resolve(bundle) }) + + const { result } = renderHookWithClient(() => taskBundle.useCreateTaskBundle()) + + result.current.mutate({ name: 'my bundle', taskIds: [7] }) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(apiRequestMock.post).toHaveBeenCalledWith('api/v2/taskBundle', { + json: { name: 'my bundle', taskIds: [7] }, + }) + expect(result.current.data).toEqual(bundle) + }) +}) diff --git a/src/api/taskBundle/queries.test.tsx b/src/api/taskBundle/queries.test.tsx new file mode 100644 index 000000000..46bbbb4bd --- /dev/null +++ b/src/api/taskBundle/queries.test.tsx @@ -0,0 +1,232 @@ +import { waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { renderHookWithClient } from '@/test/queryClient' +import type { Task } from '@/types/Task' + +const { apiRequestMock } = vi.hoisted(() => ({ + apiRequestMock: { + get: vi.fn(), + post: vi.fn(), + put: vi.fn(), + delete: vi.fn(), + }, +})) + +vi.mock('@/api/client', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, apiRequest: apiRequestMock } +}) + +import type { TaskBundleResponse } from './queries' +import { taskBundleQueries } from './queries' + +function makeTask(id: number): Task { + return { id } as unknown as Task +} + +describe('taskBundleQueries', () => { + beforeEach(() => { + apiRequestMock.get.mockReset() + apiRequestMock.post.mockReset() + apiRequestMock.put.mockReset() + apiRequestMock.delete.mockReset() + }) + + describe('getTaskBundle', () => { + it('posts to the bundle endpoint with lockTasks in the search params', async () => { + const bundle: TaskBundleResponse = { bundleId: 5, ownerId: 1, taskIds: [1, 2] } + apiRequestMock.post.mockReturnValue({ json: () => Promise.resolve(bundle) }) + + const { result } = renderHookWithClient(() => taskBundleQueries.getTaskBundle(5)) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(apiRequestMock.post).toHaveBeenCalledWith('api/v2/taskBundle/5', { + searchParams: { lockTasks: 'false' }, + }) + expect(result.current.data).toEqual(bundle) + }) + + it('passes lockTasks=true through to the search params', async () => { + const bundle: TaskBundleResponse = { bundleId: 5, ownerId: 1, taskIds: [1, 2] } + apiRequestMock.post.mockReturnValue({ json: () => Promise.resolve(bundle) }) + + const { result } = renderHookWithClient(() => taskBundleQueries.getTaskBundle(5, true)) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(apiRequestMock.post).toHaveBeenCalledWith('api/v2/taskBundle/5', { + searchParams: { lockTasks: 'true' }, + }) + }) + + it('seeds the cache for each task included in the bundle', async () => { + const task1 = makeTask(101) + const task2 = makeTask(102) + const bundle: TaskBundleResponse = { + bundleId: 5, + ownerId: 1, + taskIds: [101, 102], + tasks: [task1, task2], + } + apiRequestMock.post.mockReturnValue({ json: () => Promise.resolve(bundle) }) + + const { result, queryClient } = renderHookWithClient(() => taskBundleQueries.getTaskBundle(5)) + const setSpy = vi.spyOn(queryClient, 'setQueryData') + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(setSpy).toHaveBeenCalledWith(['task', 101], task1) + expect(setSpy).toHaveBeenCalledWith(['task', 102], task2) + }) + + it('is disabled when bundleId is falsy', () => { + const { result } = renderHookWithClient(() => taskBundleQueries.getTaskBundle(0)) + + expect(result.current.fetchStatus).toBe('idle') + expect(apiRequestMock.post).not.toHaveBeenCalled() + }) + }) + + describe('useCreateTaskBundle', () => { + it('posts the bundle payload and seeds the bundle and task caches on success', async () => { + const task = makeTask(7) + const bundle: TaskBundleResponse = { bundleId: 9, ownerId: 1, taskIds: [7], tasks: [task] } + apiRequestMock.post.mockReturnValue({ json: () => Promise.resolve(bundle) }) + + const { result, queryClient } = renderHookWithClient(() => + taskBundleQueries.useCreateTaskBundle() + ) + const setSpy = vi.spyOn(queryClient, 'setQueryData') + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries') + + result.current.mutate({ name: 'my bundle', taskIds: [7] }) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(apiRequestMock.post).toHaveBeenCalledWith('api/v2/taskBundle', { + json: { name: 'my bundle', taskIds: [7] }, + }) + expect(setSpy).toHaveBeenCalledWith(['taskBundle', 9, { lockTasks: false }], bundle) + expect(setSpy).toHaveBeenCalledWith(['task', 7], task) + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['task', 'inBounds'] }) + }) + + it('does not seed task cache entries when the response has no tasks', async () => { + const bundle: TaskBundleResponse = { bundleId: 9, ownerId: 1, taskIds: [7] } + apiRequestMock.post.mockReturnValue({ json: () => Promise.resolve(bundle) }) + + const { result, queryClient } = renderHookWithClient(() => + taskBundleQueries.useCreateTaskBundle() + ) + const setSpy = vi.spyOn(queryClient, 'setQueryData') + + result.current.mutate({ name: 'my bundle', taskIds: [7] }) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(setSpy).toHaveBeenCalledWith(['taskBundle', 9, { lockTasks: false }], bundle) + expect(setSpy).not.toHaveBeenCalledWith(['task', 7], expect.anything()) + }) + }) + + describe('useUpdateTaskBundle', () => { + it('posts the joined taskIds to the update endpoint and refreshes caches', async () => { + const task = makeTask(3) + const updatedBundle: TaskBundleResponse = { + bundleId: 4, + ownerId: 1, + taskIds: [3, 8], + tasks: [task], + } + apiRequestMock.post.mockReturnValue({ json: () => Promise.resolve(updatedBundle) }) + + const { result, queryClient } = renderHookWithClient(() => + taskBundleQueries.useUpdateTaskBundle() + ) + const setSpy = vi.spyOn(queryClient, 'setQueryData') + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries') + + result.current.mutate({ bundleId: 4, taskIds: [3, 8] }) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(apiRequestMock.post).toHaveBeenCalledWith('api/v2/taskBundle/4/update', { + searchParams: { taskIds: '3,8' }, + }) + expect(setSpy).toHaveBeenCalledWith(['taskBundle', 4, { lockTasks: false }], updatedBundle) + expect(setSpy).toHaveBeenCalledWith(['task', 3], task) + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['task', 'inBounds'] }) + }) + }) + + describe('useDeleteTaskBundle', () => { + it('deletes the bundle and removes/invalidates the relevant caches', async () => { + apiRequestMock.delete.mockReturnValue({ json: () => Promise.resolve(undefined) }) + + const { result, queryClient } = renderHookWithClient(() => + taskBundleQueries.useDeleteTaskBundle() + ) + const removeSpy = vi.spyOn(queryClient, 'removeQueries') + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries') + + result.current.mutate(12) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(apiRequestMock.delete).toHaveBeenCalledWith('api/v2/taskBundle/12') + expect(removeSpy).toHaveBeenCalledWith({ queryKey: ['taskBundle', 12] }) + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['task', 'inBounds'] }) + }) + }) + + describe('useUpdateTaskBundleStatus', () => { + it('puts the primaryId as a search param and invalidates related caches', async () => { + apiRequestMock.put.mockReturnValue(Promise.resolve(undefined)) + + const { result, queryClient } = renderHookWithClient(() => + taskBundleQueries.useUpdateTaskBundleStatus() + ) + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries') + + result.current.mutate({ bundleId: 6, primaryId: 60, status: 1 }) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(apiRequestMock.put).toHaveBeenCalledWith('api/v2/taskBundle/6/1', { + searchParams: { primaryId: '60' }, + }) + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['taskBundle', 6] }) + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['task'] }) + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['challenge'] }) + }) + + it('includes joined tags in the search params when tags are provided', async () => { + apiRequestMock.put.mockReturnValue(Promise.resolve(undefined)) + + const { result } = renderHookWithClient(() => taskBundleQueries.useUpdateTaskBundleStatus()) + + result.current.mutate({ bundleId: 6, primaryId: 60, status: 1, tags: ['foo', 'bar'] }) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(apiRequestMock.put).toHaveBeenCalledWith('api/v2/taskBundle/6/1', { + searchParams: { primaryId: '60', tags: 'foo,bar' }, + }) + }) + + it('omits the tags search param when an empty tags array is provided', async () => { + apiRequestMock.put.mockReturnValue(Promise.resolve(undefined)) + + const { result } = renderHookWithClient(() => taskBundleQueries.useUpdateTaskBundleStatus()) + + result.current.mutate({ bundleId: 6, primaryId: 60, status: 1, tags: [] }) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(apiRequestMock.put).toHaveBeenCalledWith('api/v2/taskBundle/6/1', { + searchParams: { primaryId: '60' }, + }) + }) + }) +}) diff --git a/src/api/taskBundle/queries.ts b/src/api/taskBundle/queries.ts index cd87dff91..bf0ffa76a 100644 --- a/src/api/taskBundle/queries.ts +++ b/src/api/taskBundle/queries.ts @@ -1,6 +1,6 @@ import { queryOptions, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import type { Task } from '@/types/Task' -import { apiRequest } from '../' +import { apiRequest } from '../client' export interface TaskBundleResponse { bundleId: number diff --git a/src/api/team/index.test.tsx b/src/api/team/index.test.tsx new file mode 100644 index 000000000..c0e37ab8e --- /dev/null +++ b/src/api/team/index.test.tsx @@ -0,0 +1,248 @@ +import { waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { renderHookWithClient } from '@/test/queryClient' +import type { Team, TeamUser } from '@/types/Team' + +const { apiRequestMock } = vi.hoisted(() => ({ + apiRequestMock: { + get: vi.fn(), + post: vi.fn(), + put: vi.fn(), + delete: vi.fn(), + }, +})) + +vi.mock('@/api/client', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, apiRequest: apiRequestMock } +}) + +import { team } from './index' + +function makeTeam(id: number): Team { + return { id } as unknown as Team +} + +function makeTeamUser(id: number): TeamUser { + return { id } as unknown as TeamUser +} + +describe('team', () => { + beforeEach(() => { + apiRequestMock.get.mockReset() + apiRequestMock.post.mockReset() + apiRequestMock.put.mockReset() + apiRequestMock.delete.mockReset() + }) + + describe('get', () => { + it('fetches a team by id', async () => { + const teamData = makeTeam(3) + apiRequestMock.get.mockReturnValue({ json: () => Promise.resolve(teamData) }) + + const { result } = renderHookWithClient(() => team.get(3)) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(apiRequestMock.get).toHaveBeenCalledWith('api/v2/team/3') + expect(result.current.data).toEqual(teamData) + }) + + it('is disabled when teamId is undefined', () => { + const { result } = renderHookWithClient(() => team.get(undefined)) + + expect(result.current.fetchStatus).toBe('idle') + expect(apiRequestMock.get).not.toHaveBeenCalled() + }) + }) + + describe('members', () => { + it('fetches the members of a team', async () => { + const members = [makeTeamUser(1), makeTeamUser(2)] + apiRequestMock.get.mockReturnValue({ json: () => Promise.resolve(members) }) + + const { result } = renderHookWithClient(() => team.members(3)) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(apiRequestMock.get).toHaveBeenCalledWith('api/v2/team/3/userMembers') + expect(result.current.data).toEqual(members) + }) + + it('is disabled when teamId is undefined', () => { + const { result } = renderHookWithClient(() => team.members(undefined)) + + expect(result.current.fetchStatus).toBe('idle') + expect(apiRequestMock.get).not.toHaveBeenCalled() + }) + }) + + describe('findTeamsByName', () => { + it('searches teams by name using a query search param', async () => { + const teams = [makeTeam(1)] + apiRequestMock.get.mockReturnValue({ json: () => Promise.resolve(teams) }) + + const { result } = renderHookWithClient(() => team.findTeamsByName('mappers')) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(apiRequestMock.get).toHaveBeenCalledWith('api/v2/teams/find', { + searchParams: { query: 'mappers' }, + }) + expect(result.current.data).toEqual(teams) + }) + + it('is disabled when the query string is empty', () => { + const { result } = renderHookWithClient(() => team.findTeamsByName('')) + + expect(result.current.fetchStatus).toBe('idle') + expect(apiRequestMock.get).not.toHaveBeenCalled() + }) + + it('is disabled when enabled=false even with a non-empty query', () => { + const { result } = renderHookWithClient(() => team.findTeamsByName('mappers', false)) + + expect(result.current.fetchStatus).toBe('idle') + expect(apiRequestMock.get).not.toHaveBeenCalled() + }) + }) + + describe('useCreateTeam', () => { + it('posts the payload and invalidates the user query on success', async () => { + const created = makeTeam(4) + apiRequestMock.post.mockReturnValue({ json: () => Promise.resolve(created) }) + + const { result, queryClient } = renderHookWithClient(() => team.useCreateTeam()) + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries') + + result.current.mutate({ name: 'New Team', description: 'desc' }) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(apiRequestMock.post).toHaveBeenCalledWith('api/v2/team', { + json: { name: 'New Team', description: 'desc' }, + }) + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['user'] }) + }) + }) + + describe('useUpdateTeam', () => { + it('puts the payload and invalidates the specific team query on success', async () => { + const updated = makeTeam(4) + apiRequestMock.put.mockReturnValue({ json: () => Promise.resolve(updated) }) + + const { result, queryClient } = renderHookWithClient(() => team.useUpdateTeam()) + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries') + + result.current.mutate({ teamId: 4, payload: { name: 'Renamed' } }) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(apiRequestMock.put).toHaveBeenCalledWith('api/v2/team/4', { + json: { name: 'Renamed' }, + }) + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['team', 4] }) + }) + }) + + describe('useDeleteTeam', () => { + it('deletes the team and invalidates the user and team queries on success', async () => { + apiRequestMock.delete.mockReturnValue({ json: () => Promise.resolve(undefined) }) + + const { result, queryClient } = renderHookWithClient(() => team.useDeleteTeam()) + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries') + + result.current.mutate(4) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(apiRequestMock.delete).toHaveBeenCalledWith('api/v2/team/4') + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['user'] }) + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['team'] }) + }) + }) + + describe('useInviteMember', () => { + it('puts to the invite endpoint and invalidates the members query on success', async () => { + const invited = makeTeamUser(9) + apiRequestMock.put.mockReturnValue({ json: () => Promise.resolve(invited) }) + + const { result, queryClient } = renderHookWithClient(() => team.useInviteMember()) + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries') + + result.current.mutate({ teamId: 4, userId: 9, role: 1 }) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(apiRequestMock.put).toHaveBeenCalledWith('api/v2/team/4/user/9/invite/1') + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['team', 4, 'members'] }) + }) + }) + + describe('useAcceptInvite', () => { + it('puts to the accept endpoint and invalidates team and user queries on success', async () => { + const accepted = makeTeamUser(9) + apiRequestMock.put.mockReturnValue({ json: () => Promise.resolve(accepted) }) + + const { result, queryClient } = renderHookWithClient(() => team.useAcceptInvite()) + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries') + + result.current.mutate(4) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(apiRequestMock.put).toHaveBeenCalledWith('api/v2/team/4/invite/accept') + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['team', 4] }) + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['user'] }) + }) + }) + + describe('useDeclineInvite', () => { + it('deletes the invite and invalidates the user query on success', async () => { + apiRequestMock.delete.mockReturnValue({ json: () => Promise.resolve(undefined) }) + + const { result, queryClient } = renderHookWithClient(() => team.useDeclineInvite()) + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries') + + result.current.mutate(4) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(apiRequestMock.delete).toHaveBeenCalledWith('api/v2/team/4/invite') + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['user'] }) + }) + }) + + describe('useChangeRole', () => { + it('puts the new role and invalidates the members query on success', async () => { + const changed = makeTeamUser(9) + apiRequestMock.put.mockReturnValue({ json: () => Promise.resolve(changed) }) + + const { result, queryClient } = renderHookWithClient(() => team.useChangeRole()) + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries') + + result.current.mutate({ teamId: 4, userId: 9, role: 2 }) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(apiRequestMock.put).toHaveBeenCalledWith('api/v2/team/4/user/9/role/2') + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['team', 4, 'members'] }) + }) + }) + + describe('useRemoveMember', () => { + it('deletes the member and invalidates the members query on success', async () => { + apiRequestMock.delete.mockReturnValue({ json: () => Promise.resolve(undefined) }) + + const { result, queryClient } = renderHookWithClient(() => team.useRemoveMember()) + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries') + + result.current.mutate({ teamId: 4, userId: 9 }) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(apiRequestMock.delete).toHaveBeenCalledWith('api/v2/team/4/user/9/') + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['team', 4, 'members'] }) + }) + }) +}) diff --git a/src/api/team/index.ts b/src/api/team/index.ts index 23c724249..40f3a048f 100644 --- a/src/api/team/index.ts +++ b/src/api/team/index.ts @@ -1,6 +1,6 @@ import { queryOptions, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import type { Team, TeamRole, TeamUser } from '@/types/Team' -import { apiRequest } from '../' +import { apiRequest } from '../client' export const team = { get: (teamId: number | undefined) => diff --git a/src/api/user/admin.test.tsx b/src/api/user/admin.test.tsx new file mode 100644 index 000000000..b1449f221 --- /dev/null +++ b/src/api/user/admin.test.tsx @@ -0,0 +1,76 @@ +import { waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { renderHookWithClient } from '@/test/queryClient' + +const { apiRequestMock } = vi.hoisted(() => ({ + apiRequestMock: { + get: vi.fn(), + post: vi.fn(), + put: vi.fn(), + delete: vi.fn(), + }, +})) + +vi.mock('@/api/client', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, apiRequest: apiRequestMock } +}) + +import { userAdmin } from './admin' + +describe('userAdmin', () => { + beforeEach(() => { + apiRequestMock.get.mockReset() + }) + + it('getAllUsers fetches users with default limit and page', async () => { + const users = [{ id: 1 }, { id: 2 }] + apiRequestMock.get.mockReturnValue({ json: () => Promise.resolve(users) }) + + const { result } = renderHookWithClient(() => userAdmin.getAllUsers()) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(apiRequestMock.get).toHaveBeenCalledWith('api/v2/super-admin/users', { + searchParams: { limit: 50, page: 0 }, + }) + expect(result.current.data).toEqual(users) + }) + + it('getAllUsers passes through explicit limit and page', async () => { + const users = [{ id: 3 }] + apiRequestMock.get.mockReturnValue({ json: () => Promise.resolve(users) }) + + const { result } = renderHookWithClient(() => userAdmin.getAllUsers({ limit: 20, page: 2 })) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(apiRequestMock.get).toHaveBeenCalledWith('api/v2/super-admin/users', { + searchParams: { limit: 20, page: 2 }, + }) + }) + + it('getAllUsers caches each returned user under its own query key', async () => { + const users = [{ id: 1 }, { id: 2 }] + apiRequestMock.get.mockReturnValue({ json: () => Promise.resolve(users) }) + + const { result, queryClient } = renderHookWithClient(() => userAdmin.getAllUsers()) + const setQueryDataSpy = vi.spyOn(queryClient, 'setQueryData') + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(setQueryDataSpy).toHaveBeenCalledWith(['user', 1], users[0]) + expect(setQueryDataSpy).toHaveBeenCalledWith(['user', 2], users[1]) + }) + + it('getSuperUsers fetches the superusers list', async () => { + apiRequestMock.get.mockReturnValue({ json: () => Promise.resolve([1, 2, 3]) }) + + const { result } = renderHookWithClient(() => userAdmin.getSuperUsers()) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(apiRequestMock.get).toHaveBeenCalledWith('api/v2/superusers') + expect(result.current.data).toEqual([1, 2, 3]) + }) +}) diff --git a/src/api/user/admin.ts b/src/api/user/admin.ts index d93bfa999..aa4141116 100644 --- a/src/api/user/admin.ts +++ b/src/api/user/admin.ts @@ -1,6 +1,6 @@ import { queryOptions, useQuery, useQueryClient } from '@tanstack/react-query' import type { GetAllUsersParams, User } from '@/types/User' -import { apiRequest, convertParamsToSearchParams } from '../' +import { apiRequest, convertParamsToSearchParams } from '../client' export const userAdmin = { getAllUsers: (params?: GetAllUsersParams) => { diff --git a/src/api/user/auth.test.tsx b/src/api/user/auth.test.tsx new file mode 100644 index 000000000..a3d5bd8d3 --- /dev/null +++ b/src/api/user/auth.test.tsx @@ -0,0 +1,85 @@ +import { waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { renderHookWithClient } from '@/test/queryClient' + +type MutableEnv = { VITE_APP_URL?: string } + +const { apiRequestMock } = vi.hoisted(() => ({ + apiRequestMock: { + get: vi.fn(), + post: vi.fn(), + put: vi.fn(), + delete: vi.fn(), + }, +})) + +vi.mock('@/api/client', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, apiRequest: apiRequestMock } +}) + +import { userAuth } from './auth' + +describe('userAuth', () => { + beforeEach(() => { + apiRequestMock.get.mockReset() + apiRequestMock.post.mockReset() + }) + + it('signOut calls the signout endpoint', async () => { + apiRequestMock.get.mockReturnValue({ json: () => Promise.resolve(undefined) }) + + await userAuth.signOut() + + expect(apiRequestMock.get).toHaveBeenCalledWith('auth/signout') + }) + + it('callback encodes the redirect_uri from window.env.VITE_APP_URL', async () => { + apiRequestMock.get.mockReturnValue({ json: () => Promise.resolve({ token: 'abc' }) }) + ;(window.env as unknown as MutableEnv).VITE_APP_URL = 'https://example.test' + + const result = await userAuth.callback('the-code') + + expect(apiRequestMock.get).toHaveBeenCalledWith( + 'auth/callback?code=the-code&redirect_uri=https%3A%2F%2Fexample.test' + ) + expect(result).toEqual({ token: 'abc' }) + }) + + it('whoAmI fetches the current user when not logged out', async () => { + apiRequestMock.get.mockReturnValue({ json: () => Promise.resolve({ id: 7 }) }) + + const { result } = renderHookWithClient(() => userAuth.whoAmI(false)) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(apiRequestMock.get).toHaveBeenCalledWith('api/v2/user/whoami') + expect(result.current.data).toEqual({ id: 7 }) + }) + + it('whoAmI does not fetch when the user is logged out', () => { + const { result } = renderHookWithClient(() => userAuth.whoAmI(true)) + + expect(result.current.fetchStatus).toBe('idle') + expect(apiRequestMock.get).not.toHaveBeenCalled() + }) + + it('refreshAuth invalidates the whoami and user query keys', async () => { + const { queryClient } = renderHookWithClient(() => userAuth.whoAmI(true)) + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries') + + await userAuth.refreshAuth(queryClient) + + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['user', 'whoami'] }) + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['user'] }) + }) + + it('clearAuth removes the whoami query', () => { + const { queryClient } = renderHookWithClient(() => userAuth.whoAmI(true)) + const removeSpy = vi.spyOn(queryClient, 'removeQueries') + + userAuth.clearAuth(queryClient) + + expect(removeSpy).toHaveBeenCalledWith({ queryKey: ['user', 'whoami'] }) + }) +}) diff --git a/src/api/user/auth.ts b/src/api/user/auth.ts index 182b51c5d..aa2b69fbf 100644 --- a/src/api/user/auth.ts +++ b/src/api/user/auth.ts @@ -2,7 +2,7 @@ import type { QueryClient } from '@tanstack/react-query' import { queryOptions, useQuery } from '@tanstack/react-query' import type { OAuthCallbackResponse } from '@/types/Oauth' import type { UserWhoamiResponse } from '@/types/User' -import { apiRequest } from '../' +import { apiRequest } from '../client' export const userAuth = { signOut: async () => await apiRequest.get('auth/signout').json(), diff --git a/src/api/user/index.test.ts b/src/api/user/index.test.ts new file mode 100644 index 000000000..47f84ddf9 --- /dev/null +++ b/src/api/user/index.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest' +import { user } from './index' + +describe('user barrel export', () => { + it('re-exports the auth API', () => { + expect(typeof user.signOut).toBe('function') + expect(typeof user.callback).toBe('function') + expect(typeof user.whoAmI).toBe('function') + expect(typeof user.whoAmIOptions).toBe('function') + expect(typeof user.refreshAuth).toBe('function') + expect(typeof user.clearAuth).toBe('function') + }) + + it('re-exports the profile API', () => { + expect(typeof user.getUser).toBe('function') + expect(typeof user.activity).toBe('function') + expect(typeof user.metrics).toBe('function') + expect(typeof user.topChallenges).toBe('function') + expect(typeof user.savedChallenges).toBe('function') + expect(typeof user.savedTasks).toBe('function') + expect(typeof user.lockedTasks).toBe('function') + expect(typeof user.teamMemberships).toBe('function') + expect(typeof user.useUpdateUserSettings).toBe('function') + expect(typeof user.useRegenerateApiKey).toBe('function') + }) + + it('re-exports the notifications API', () => { + expect(typeof user.notification).toBe('function') + expect(typeof user.useMarkNotificationsAsRead).toBe('function') + expect(typeof user.useMarkNotificationsAsUnread).toBe('function') + expect(typeof user.useDeleteNotifications).toBe('function') + }) + + it('re-exports the admin API', () => { + expect(typeof user.getAllUsers).toBe('function') + expect(typeof user.getSuperUsers).toBe('function') + }) + + it('re-exports the search API', () => { + expect(typeof user.findUsers).toBe('function') + }) +}) diff --git a/src/api/user/notifications.test.tsx b/src/api/user/notifications.test.tsx new file mode 100644 index 000000000..e7e66be61 --- /dev/null +++ b/src/api/user/notifications.test.tsx @@ -0,0 +1,132 @@ +import { waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { renderHookWithClient } from '@/test/queryClient' +import type { UserNotificationsResponse } from '@/types/User' + +const { apiRequestMock } = vi.hoisted(() => ({ + apiRequestMock: { + get: vi.fn(), + post: vi.fn(), + put: vi.fn(), + delete: vi.fn(), + }, +})) + +vi.mock('@/api/client', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, apiRequest: apiRequestMock } +}) + +import { userNotifications } from './notifications' + +function makeNotification(id: number, isRead: boolean): UserNotificationsResponse[number] { + return { + id, + isRead, + } as unknown as UserNotificationsResponse[number] +} + +describe('userNotifications', () => { + beforeEach(() => { + apiRequestMock.get.mockReset() + apiRequestMock.put.mockReset() + }) + + it('notification fetches notifications for a user with no params', async () => { + const notifications = [makeNotification(1, false)] + apiRequestMock.get.mockReturnValue({ json: () => Promise.resolve(notifications) }) + + const { result } = renderHookWithClient(() => userNotifications.notification(5)) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(apiRequestMock.get).toHaveBeenCalledWith('api/v2/user/5/notifications', { + searchParams: {}, + }) + expect(result.current.data).toEqual(notifications) + }) + + it('notification forwards query params as search params', async () => { + apiRequestMock.get.mockReturnValue({ json: () => Promise.resolve([]) }) + + const { result } = renderHookWithClient(() => + userNotifications.notification(5, { limit: 10, page: 1 }) + ) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(apiRequestMock.get).toHaveBeenCalledWith('api/v2/user/5/notifications', { + searchParams: { limit: 10, page: 1 }, + }) + }) + + it('notification is disabled when there is no userId', () => { + const { result } = renderHookWithClient(() => userNotifications.notification(undefined)) + + expect(result.current.fetchStatus).toBe('idle') + expect(apiRequestMock.get).not.toHaveBeenCalled() + }) + + it('useMarkNotificationsAsRead PUTs the notification ids and marks them read in the cache', async () => { + apiRequestMock.put.mockReturnValue({ json: () => Promise.resolve({}) }) + + const { result, queryClient } = renderHookWithClient(() => + userNotifications.useMarkNotificationsAsRead() + ) + const queryKey = ['user', 'notifications', 5, undefined] + queryClient.setQueryData(queryKey, [makeNotification(1, false), makeNotification(2, false)]) + + result.current.mutate({ userId: 5, notificationIds: [1] }) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(apiRequestMock.put).toHaveBeenCalledWith('api/v2/user/5/notifications', { + json: { notificationIds: [1] }, + }) + expect(queryClient.getQueryData(queryKey)).toEqual([ + makeNotification(1, true), + makeNotification(2, false), + ]) + }) + + it('useMarkNotificationsAsUnread PUTs to the unread endpoint and marks them unread in the cache', async () => { + apiRequestMock.put.mockReturnValue({ json: () => Promise.resolve({}) }) + + const { result, queryClient } = renderHookWithClient(() => + userNotifications.useMarkNotificationsAsUnread() + ) + const queryKey = ['user', 'notifications', 5, undefined] + queryClient.setQueryData(queryKey, [makeNotification(1, true), makeNotification(2, true)]) + + result.current.mutate({ userId: 5, notificationIds: [2] }) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(apiRequestMock.put).toHaveBeenCalledWith('api/v2/user/5/notifications/unread', { + json: { notificationIds: [2] }, + }) + expect(queryClient.getQueryData(queryKey)).toEqual([ + makeNotification(1, true), + makeNotification(2, false), + ]) + }) + + it('useDeleteNotifications PUTs to the delete endpoint and removes them from the cache', async () => { + apiRequestMock.put.mockReturnValue({ json: () => Promise.resolve({}) }) + + const { result, queryClient } = renderHookWithClient(() => + userNotifications.useDeleteNotifications() + ) + const queryKey = ['user', 'notifications', 5, undefined] + queryClient.setQueryData(queryKey, [makeNotification(1, true), makeNotification(2, true)]) + + result.current.mutate({ userId: 5, notificationIds: [1] }) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(apiRequestMock.put).toHaveBeenCalledWith('api/v2/user/5/notifications/delete', { + json: { notificationIds: [1] }, + }) + expect(queryClient.getQueryData(queryKey)).toEqual([makeNotification(2, true)]) + }) +}) diff --git a/src/api/user/notifications.ts b/src/api/user/notifications.ts index 75c9ba78d..b80db9fa5 100644 --- a/src/api/user/notifications.ts +++ b/src/api/user/notifications.ts @@ -1,6 +1,6 @@ import { queryOptions, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import type { User, UserNotificationsParams, UserNotificationsResponse } from '@/types/User' -import { apiRequest, convertParamsToSearchParams } from '../' +import { apiRequest, convertParamsToSearchParams } from '../client' export const userNotifications = { notification: (userId: number | undefined, params?: UserNotificationsParams) => diff --git a/src/api/user/profile.test.tsx b/src/api/user/profile.test.tsx new file mode 100644 index 000000000..ab1f45b0c --- /dev/null +++ b/src/api/user/profile.test.tsx @@ -0,0 +1,226 @@ +import { waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { renderHookWithClient } from '@/test/queryClient' +import type { UserSettings } from '@/types/User' + +const { apiRequestMock } = vi.hoisted(() => ({ + apiRequestMock: { + get: vi.fn(), + post: vi.fn(), + put: vi.fn(), + delete: vi.fn(), + }, +})) + +vi.mock('@/api/client', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, apiRequest: apiRequestMock } +}) + +import { userProfile } from './profile' + +describe('userProfile', () => { + beforeEach(() => { + apiRequestMock.get.mockReset() + apiRequestMock.put.mockReset() + }) + + it('getUser fetches a user by id', async () => { + apiRequestMock.get.mockReturnValue({ json: () => Promise.resolve({ id: 5 }) }) + + const { result } = renderHookWithClient(() => userProfile.getUser(5)) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(apiRequestMock.get).toHaveBeenCalledWith('api/v2/user/5') + expect(result.current.data).toEqual({ id: 5 }) + }) + + it('getUser is disabled when the userId is falsy', () => { + const { result } = renderHookWithClient(() => userProfile.getUser(0)) + + expect(result.current.fetchStatus).toBe('idle') + expect(apiRequestMock.get).not.toHaveBeenCalled() + }) + + it('activity fetches the user activity feed', async () => { + apiRequestMock.get.mockReturnValue({ json: () => Promise.resolve([{ id: 1 }]) }) + + const { result } = renderHookWithClient(() => userProfile.activity()) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(apiRequestMock.get).toHaveBeenCalledWith('api/v2/data/user/activity') + expect(result.current.data).toEqual([{ id: 1 }]) + }) + + it('metrics fetches user metrics with the default monthDuration', async () => { + apiRequestMock.get.mockReturnValue({ json: () => Promise.resolve({ total: 10 }) }) + + const { result } = renderHookWithClient(() => userProfile.metrics(5)) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(apiRequestMock.get).toHaveBeenCalledWith('api/v2/data/user/5/metrics', { + searchParams: { monthDuration: -1 }, + }) + expect(result.current.data).toEqual({ total: 10 }) + }) + + it('metrics forwards an explicit monthDuration', async () => { + apiRequestMock.get.mockReturnValue({ json: () => Promise.resolve({ total: 3 }) }) + + const { result } = renderHookWithClient(() => userProfile.metrics(5, 6)) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(apiRequestMock.get).toHaveBeenCalledWith('api/v2/data/user/5/metrics', { + searchParams: { monthDuration: 6 }, + }) + }) + + it('metrics is disabled when there is no userId', () => { + const { result } = renderHookWithClient(() => userProfile.metrics(undefined)) + + expect(result.current.fetchStatus).toBe('idle') + expect(apiRequestMock.get).not.toHaveBeenCalled() + }) + + it('topChallenges fetches with default paging params', async () => { + apiRequestMock.get.mockReturnValue({ json: () => Promise.resolve([{ id: 1 }]) }) + + const { result } = renderHookWithClient(() => userProfile.topChallenges(5)) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(apiRequestMock.get).toHaveBeenCalledWith('api/v2/data/user/5/topChallenges', { + searchParams: { monthDuration: -1, limit: 10, offset: 0 }, + }) + }) + + it('topChallenges forwards explicit paging params', async () => { + apiRequestMock.get.mockReturnValue({ json: () => Promise.resolve([]) }) + + const { result } = renderHookWithClient(() => + userProfile.topChallenges(5, { monthDuration: 3, limit: 25, offset: 50 }) + ) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(apiRequestMock.get).toHaveBeenCalledWith('api/v2/data/user/5/topChallenges', { + searchParams: { monthDuration: 3, limit: 25, offset: 50 }, + }) + }) + + it('savedChallenges fetches with the given limit and page', async () => { + apiRequestMock.get.mockReturnValue({ json: () => Promise.resolve([{ id: 9 }]) }) + + const { result } = renderHookWithClient(() => userProfile.savedChallenges(5, 20, 1)) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(apiRequestMock.get).toHaveBeenCalledWith('api/v2/user/5/saved', { + searchParams: { limit: 20, page: 1 }, + }) + expect(result.current.data).toEqual([{ id: 9 }]) + }) + + it('savedTasks fetches with the given limit and page', async () => { + apiRequestMock.get.mockReturnValue({ json: () => Promise.resolve([{ id: 11 }]) }) + + const { result } = renderHookWithClient(() => userProfile.savedTasks(5, 20, 1)) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(apiRequestMock.get).toHaveBeenCalledWith('api/v2/user/5/savedTasks', { + searchParams: { limit: 20, page: 1 }, + }) + expect(result.current.data).toEqual([{ id: 11 }]) + }) + + it('lockedTasks fetches with the default limit', async () => { + apiRequestMock.get.mockReturnValue({ json: () => Promise.resolve([{ id: 4 }]) }) + + const { result } = renderHookWithClient(() => userProfile.lockedTasks(5)) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(apiRequestMock.get).toHaveBeenCalledWith('api/v2/user/5/lockedTasks', { + searchParams: { limit: 50 }, + }) + }) + + it('teamMemberships fetches the memberships for a user', async () => { + apiRequestMock.get.mockReturnValue({ json: () => Promise.resolve([{ id: 2 }]) }) + + const { result } = renderHookWithClient(() => userProfile.teamMemberships(5)) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(apiRequestMock.get).toHaveBeenCalledWith('api/v2/team/all/user/5/memberships') + expect(result.current.data).toEqual([{ id: 2 }]) + }) + + it('teamMemberships is disabled when there is no userId', () => { + const { result } = renderHookWithClient(() => userProfile.teamMemberships(undefined)) + + expect(result.current.fetchStatus).toBe('idle') + expect(apiRequestMock.get).not.toHaveBeenCalled() + }) + + it('useUpdateUserSettings PUTs settings and stringifies properties, then updates the whoami cache', async () => { + const updatedUser = { id: 5, name: 'Updated' } + apiRequestMock.put.mockReturnValue({ json: () => Promise.resolve(updatedUser) }) + + const { result, queryClient } = renderHookWithClient(() => userProfile.useUpdateUserSettings()) + const setQueryDataSpy = vi.spyOn(queryClient, 'setQueryData') + + result.current.mutate({ + userId: 5, + settings: { defaultEditor: 1 } as unknown as UserSettings, + properties: { theme: 'dark' }, + }) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(apiRequestMock.put).toHaveBeenCalledWith('api/v2/user/5', { + json: { defaultEditor: 1, properties: JSON.stringify({ theme: 'dark' }) }, + }) + expect(setQueryDataSpy).toHaveBeenCalledWith(['user', 'whoami'], updatedUser) + }) + + it('useUpdateUserSettings omits the properties field when none are given', async () => { + const updatedUser = { id: 5, name: 'Updated' } + apiRequestMock.put.mockReturnValue({ json: () => Promise.resolve(updatedUser) }) + + const { result } = renderHookWithClient(() => userProfile.useUpdateUserSettings()) + + result.current.mutate({ + userId: 5, + settings: { defaultEditor: 1 } as unknown as UserSettings, + }) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(apiRequestMock.put).toHaveBeenCalledWith('api/v2/user/5', { + json: { defaultEditor: 1 }, + }) + }) + + it('useRegenerateApiKey PUTs to the apikey endpoint, updates the whoami cache, and invalidates the user query', async () => { + const updatedUser = { id: 5, apiKey: 'new-key' } + apiRequestMock.put.mockReturnValue({ json: () => Promise.resolve(updatedUser) }) + + const { result, queryClient } = renderHookWithClient(() => userProfile.useRegenerateApiKey()) + const setQueryDataSpy = vi.spyOn(queryClient, 'setQueryData') + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries') + + result.current.mutate(5) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(apiRequestMock.put).toHaveBeenCalledWith('api/v2/user/5/apikey') + expect(setQueryDataSpy).toHaveBeenCalledWith(['user', 'whoami'], updatedUser) + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['user', 5] }) + }) +}) diff --git a/src/api/user/profile.ts b/src/api/user/profile.ts index e625188ce..282e807e9 100644 --- a/src/api/user/profile.ts +++ b/src/api/user/profile.ts @@ -3,7 +3,7 @@ import type { Challenge } from '@/types/Challenge' import type { components } from '@/types/openApiTypes' import type { Task } from '@/types/Task' import type { User, UserMetricsResponse, UserProperties, UserSettings } from '@/types/User' -import { apiRequest } from '../' +import { apiRequest } from '../client' export type LockedTaskData = components['schemas']['org.maproulette.framework.model.LockedTaskData'] export type TeamUser = components['schemas']['org.maproulette.framework.model.TeamUser'] diff --git a/src/api/user/search.test.tsx b/src/api/user/search.test.tsx new file mode 100644 index 000000000..5e546ac6c --- /dev/null +++ b/src/api/user/search.test.tsx @@ -0,0 +1,77 @@ +import { waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { renderHookWithClient } from '@/test/queryClient' + +const { apiRequestMock } = vi.hoisted(() => ({ + apiRequestMock: { + get: vi.fn(), + post: vi.fn(), + put: vi.fn(), + delete: vi.fn(), + }, +})) + +vi.mock('@/api/client', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, apiRequest: apiRequestMock } +}) + +import { userSearch } from './search' + +describe('userSearch', () => { + beforeEach(() => { + apiRequestMock.get.mockReset() + }) + + it('findUsers searches with the default limit', async () => { + const users = [{ id: 1 }] + apiRequestMock.get.mockReturnValue({ json: () => Promise.resolve(users) }) + + const { result } = renderHookWithClient(() => userSearch.findUsers('bob')) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(apiRequestMock.get).toHaveBeenCalledWith('api/v2/users/find/bob', { + searchParams: { limit: 10 }, + }) + expect(result.current.data).toEqual(users) + }) + + it('findUsers forwards an explicit limit', async () => { + apiRequestMock.get.mockReturnValue({ json: () => Promise.resolve([]) }) + + const { result } = renderHookWithClient(() => userSearch.findUsers('bob', 5)) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(apiRequestMock.get).toHaveBeenCalledWith('api/v2/users/find/bob', { + searchParams: { limit: 5 }, + }) + }) + + it('findUsers URI-encodes the prefix', async () => { + apiRequestMock.get.mockReturnValue({ json: () => Promise.resolve([]) }) + + const { result } = renderHookWithClient(() => userSearch.findUsers('a b/c')) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(apiRequestMock.get).toHaveBeenCalledWith('api/v2/users/find/a%20b%2Fc', { + searchParams: { limit: 10 }, + }) + }) + + it('findUsers is disabled when the prefix is empty', () => { + const { result } = renderHookWithClient(() => userSearch.findUsers('')) + + expect(result.current.fetchStatus).toBe('idle') + expect(apiRequestMock.get).not.toHaveBeenCalled() + }) + + it('findUsers is disabled when the caller explicitly disables it', () => { + const { result } = renderHookWithClient(() => userSearch.findUsers('bob', 10, false)) + + expect(result.current.fetchStatus).toBe('idle') + expect(apiRequestMock.get).not.toHaveBeenCalled() + }) +}) diff --git a/src/api/user/search.ts b/src/api/user/search.ts index 9801e0ea9..a0242070e 100644 --- a/src/api/user/search.ts +++ b/src/api/user/search.ts @@ -1,6 +1,6 @@ import { queryOptions, useQuery } from '@tanstack/react-query' import type { User } from '@/types/User' -import { apiRequest } from '../' +import { apiRequest } from '../client' export const userSearch = { findUsers: (prefix: string, limit: number = 10, enabled: boolean = true) => diff --git a/src/components/Map/TaskMarkers/useChallengeTypes.test.tsx b/src/components/Map/TaskMarkers/useChallengeTypes.test.tsx new file mode 100644 index 000000000..657e38537 --- /dev/null +++ b/src/components/Map/TaskMarkers/useChallengeTypes.test.tsx @@ -0,0 +1,101 @@ +import { waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { renderHookWithClient } from '@/test/queryClient' + +const { apiRequestMock } = vi.hoisted(() => ({ + apiRequestMock: { + get: vi.fn(), + post: vi.fn(), + put: vi.fn(), + delete: vi.fn(), + }, +})) + +vi.mock('@/api/client', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, apiRequest: apiRequestMock } +}) + +import { useChallengeTypes } from './useChallengeTypes' + +const tagsResponse = (names: string[]) => ({ + json: () => Promise.resolve(names.map((name, i) => ({ id: i, name }))), +}) + +describe('useChallengeTypes', () => { + beforeEach(() => { + apiRequestMock.get.mockReset() + }) + + it('returns an empty map for an empty challenge id list', () => { + const { result } = renderHookWithClient(() => useChallengeTypes([])) + + expect(result.current).toEqual(new Map()) + expect(apiRequestMock.get).not.toHaveBeenCalled() + }) + + it('resolves a single challenge id to its task type based on tags', async () => { + apiRequestMock.get.mockReturnValue(tagsResponse(['river', 'unrelated'])) + + const { result } = renderHookWithClient(() => useChallengeTypes([1])) + + await waitFor(() => expect(result.current.get(1)).toBe('water')) + + expect(apiRequestMock.get).toHaveBeenCalledWith('api/v2/challenge/1/tags') + }) + + it('resolves multiple challenge ids independently', async () => { + apiRequestMock.get.mockImplementation((url: string) => { + if (url === 'api/v2/challenge/1/tags') return tagsResponse(['road']) + if (url === 'api/v2/challenge/2/tags') return tagsResponse(['bicycle']) + return tagsResponse([]) + }) + + const { result } = renderHookWithClient(() => useChallengeTypes([1, 2, 3])) + + await waitFor(() => { + expect(result.current.get(1)).toBe('road') + expect(result.current.get(2)).toBe('bike') + }) + + expect(result.current.has(3)).toBe(false) + expect(result.current.size).toBe(2) + }) + + it('omits challenges whose tags do not resolve to a known task type', async () => { + apiRequestMock.get.mockReturnValue(tagsResponse(['mystery', 'unknown'])) + + const { result } = renderHookWithClient(() => useChallengeTypes([5])) + + await waitFor(() => expect(apiRequestMock.get).toHaveBeenCalledWith('api/v2/challenge/5/tags')) + + expect(result.current.has(5)).toBe(false) + }) + + it('does not query for non-positive challenge ids', () => { + const { result } = renderHookWithClient(() => useChallengeTypes([0, -1])) + + expect(apiRequestMock.get).not.toHaveBeenCalled() + expect(result.current.size).toBe(0) + }) + + it('recomputes the map when the challenge id list changes', async () => { + apiRequestMock.get.mockImplementation((url: string) => { + if (url === 'api/v2/challenge/1/tags') return tagsResponse(['forest']) + if (url === 'api/v2/challenge/2/tags') return tagsResponse(['building']) + return tagsResponse([]) + }) + + const { result, rerender } = renderHookWithClient((ids: number[]) => useChallengeTypes(ids), { + initialProps: [1], + }) + + await waitFor(() => expect(result.current.get(1)).toBe('forest')) + expect(result.current.size).toBe(1) + + rerender([2]) + + await waitFor(() => expect(result.current.get(2)).toBe('building')) + expect(result.current.has(1)).toBe(false) + }) +}) diff --git a/src/components/Pages/BrowsedChallengePage/ChallengePanel/useScrollIndicator.test.tsx b/src/components/Pages/BrowsedChallengePage/ChallengePanel/useScrollIndicator.test.tsx new file mode 100644 index 000000000..d0b659e13 --- /dev/null +++ b/src/components/Pages/BrowsedChallengePage/ChallengePanel/useScrollIndicator.test.tsx @@ -0,0 +1,175 @@ +import { act, renderHook } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mockContext: { challenge: { description?: string; blurb?: string } } = { + challenge: { description: 'initial description', blurb: 'initial blurb' }, +} + +vi.mock('@/components/Pages/BrowsedChallengePage/contexts/BrowsedChallengeContext', () => ({ + useBrowsedChallengeContext: () => mockContext, +})) + +import { useScrollIndicator } from './useScrollIndicator' + +const makeViewport = ({ + scrollHeight, + clientHeight, + scrollTop, +}: { + scrollHeight: number + clientHeight: number + scrollTop: number +}) => { + const container = document.createElement('div') + const viewport = document.createElement('div') + viewport.setAttribute('data-slot', 'scroll-area-viewport') + container.appendChild(viewport) + + Object.defineProperty(viewport, 'scrollHeight', { value: scrollHeight, configurable: true }) + Object.defineProperty(viewport, 'clientHeight', { value: clientHeight, configurable: true }) + Object.defineProperty(viewport, 'scrollTop', { + value: scrollTop, + configurable: true, + writable: true, + }) + + return { container, viewport } +} + +describe('useScrollIndicator', () => { + beforeEach(() => { + mockContext.challenge = { description: 'initial description', blurb: 'initial blurb' } + }) + + it('returns hasMoreToScroll false and a ref with no current element initially', () => { + const { result } = renderHook(() => useScrollIndicator()) + + expect(result.current.hasMoreToScroll).toBe(false) + expect(result.current.scrollAreaRef.current).toBeNull() + }) + + it('sets hasMoreToScroll to true when the viewport can scroll further', () => { + const { result, rerender } = renderHook(() => useScrollIndicator()) + const { container } = makeViewport({ scrollHeight: 200, clientHeight: 100, scrollTop: 0 }) + + act(() => { + result.current.scrollAreaRef.current = container + }) + mockContext.challenge = { description: 'changed description', blurb: 'initial blurb' } + rerender() + + expect(result.current.hasMoreToScroll).toBe(true) + }) + + it('sets hasMoreToScroll to false when the viewport is scrolled near the bottom', () => { + const { result, rerender } = renderHook(() => useScrollIndicator()) + const { container, viewport } = makeViewport({ + scrollHeight: 200, + clientHeight: 100, + scrollTop: 0, + }) + + act(() => { + result.current.scrollAreaRef.current = container + }) + mockContext.challenge = { description: 'changed description', blurb: 'initial blurb' } + rerender() + expect(result.current.hasMoreToScroll).toBe(true) + + Object.defineProperty(viewport, 'scrollTop', { value: 95, configurable: true }) + mockContext.challenge = { description: 'changed again', blurb: 'initial blurb' } + rerender() + + expect(result.current.hasMoreToScroll).toBe(false) + }) + + it('reacts to scroll events dispatched on the viewport element', () => { + const { result, rerender } = renderHook(() => useScrollIndicator()) + const { container, viewport } = makeViewport({ + scrollHeight: 200, + clientHeight: 100, + scrollTop: 95, + }) + + act(() => { + result.current.scrollAreaRef.current = container + }) + mockContext.challenge = { description: 'changed description', blurb: 'initial blurb' } + rerender() + expect(result.current.hasMoreToScroll).toBe(false) + + Object.defineProperty(viewport, 'scrollTop', { value: 0, configurable: true }) + act(() => { + viewport.dispatchEvent(new Event('scroll')) + }) + + expect(result.current.hasMoreToScroll).toBe(true) + }) + + it('reacts to window resize events', () => { + const { result, rerender } = renderHook(() => useScrollIndicator()) + const { container, viewport } = makeViewport({ + scrollHeight: 200, + clientHeight: 100, + scrollTop: 0, + }) + + act(() => { + result.current.scrollAreaRef.current = container + }) + mockContext.challenge = { description: 'changed description', blurb: 'initial blurb' } + rerender() + expect(result.current.hasMoreToScroll).toBe(true) + + Object.defineProperty(viewport, 'scrollHeight', { value: 100, configurable: true }) + act(() => { + window.dispatchEvent(new Event('resize')) + }) + + expect(result.current.hasMoreToScroll).toBe(false) + }) + + it('registers listeners on the viewport/window and removes them on unmount', () => { + const { result, rerender, unmount } = renderHook(() => useScrollIndicator()) + const { container, viewport } = makeViewport({ + scrollHeight: 200, + clientHeight: 100, + scrollTop: 0, + }) + + const viewportAddSpy = vi.spyOn(viewport, 'addEventListener') + const windowAddSpy = vi.spyOn(window, 'addEventListener') + + act(() => { + result.current.scrollAreaRef.current = container + }) + mockContext.challenge = { description: 'changed description', blurb: 'initial blurb' } + rerender() + + expect(viewportAddSpy).toHaveBeenCalledWith('scroll', expect.any(Function)) + expect(windowAddSpy).toHaveBeenCalledWith('resize', expect.any(Function)) + + const viewportRemoveSpy = vi.spyOn(viewport, 'removeEventListener') + const windowRemoveSpy = vi.spyOn(window, 'removeEventListener') + + unmount() + + expect(viewportRemoveSpy).toHaveBeenCalledWith('scroll', expect.any(Function)) + expect(windowRemoveSpy).toHaveBeenCalledWith('resize', expect.any(Function)) + }) + + it('does not re-check scroll position when unrelated challenge fields are unchanged', () => { + const { result, rerender } = renderHook(() => useScrollIndicator()) + const { container } = makeViewport({ scrollHeight: 200, clientHeight: 100, scrollTop: 0 }) + + act(() => { + result.current.scrollAreaRef.current = container + }) + // Rerender without changing description/blurb: effect should not re-run, + // so hasMoreToScroll stays at its initial false value even though the + // (now-attached) viewport would otherwise indicate more content. + rerender() + + expect(result.current.hasMoreToScroll).toBe(false) + }) +}) diff --git a/src/components/Pages/ManagementPages/ManageChallengeNew/ChallengeForm.test.tsx b/src/components/Pages/ManagementPages/ManageChallengeNew/ChallengeForm.test.tsx new file mode 100644 index 000000000..e5a124674 --- /dev/null +++ b/src/components/Pages/ManagementPages/ManageChallengeNew/ChallengeForm.test.tsx @@ -0,0 +1,194 @@ +import { cleanup, render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { Challenge } from '@/types/Challenge' +import type { User } from '@/types/User' +import { ChallengeForm } from './ChallengeForm.tsx' + +const { apiProjectMock, useAuthContextMock, useChallengeFormContextMock } = vi.hoisted(() => ({ + apiProjectMock: { + getProject: vi.fn(), + getManagedProjects: vi.fn(), + }, + useAuthContextMock: vi.fn(), + useChallengeFormContextMock: vi.fn(), +})) + +vi.mock('@/api', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + api: { + ...actual.api, + project: { + ...actual.api.project, + getProject: apiProjectMock.getProject, + getManagedProjects: apiProjectMock.getManagedProjects, + }, + }, + } +}) + +vi.mock('@/contexts/AuthContext', () => ({ + useAuthContext: useAuthContextMock, +})) + +vi.mock('@/contexts/ChallengeFormContext', () => ({ + useChallengeFormContext: useChallengeFormContextMock, +})) + +const fakeUser = { osmProfile: { id: 1, displayName: 'TestUser' }, grants: [] } as unknown as User + +afterEach(() => cleanup()) + +beforeEach(() => { + vi.clearAllMocks() + apiProjectMock.getProject.mockReturnValue({ data: undefined, isLoading: false }) + apiProjectMock.getManagedProjects.mockReturnValue({ + data: [], + isLoading: false, + isFetching: false, + }) + useAuthContextMock.mockReturnValue({ + user: fakeUser, + isAuthenticated: true, + authLoading: false, + error: null, + login: vi.fn(), + logout: vi.fn(), + }) +}) + +const renderCreateForm = (projectId?: number) => { + const onSubmit = vi.fn().mockResolvedValue(undefined) + const onCancel = vi.fn() + useChallengeFormContextMock.mockReturnValue({ + challenge: undefined, + projectId, + isLoading: false, + onSubmit, + onCancel, + }) + render() + return { onSubmit, onCancel } +} + +const renderEditForm = (challenge: Challenge) => { + const onSubmit = vi.fn().mockResolvedValue(undefined) + const onCancel = vi.fn() + useChallengeFormContextMock.mockReturnValue({ + challenge, + projectId: challenge.parent, + isLoading: false, + onSubmit, + onCancel, + }) + render() + return { onSubmit, onCancel } +} + +describe('ChallengeForm validation (create mode)', () => { + it('shows every required-field error when submitting a blank form', async () => { + const user = userEvent.setup() + const { onSubmit } = renderCreateForm(undefined) + + await user.click(screen.getByRole('button', { name: /create challenge/i })) + + expect(await screen.findByText('Please select a project')).toBeDefined() + expect(screen.getByText('Challenge name must be at least 3 characters')).toBeDefined() + expect(screen.getByText('Description is required')).toBeDefined() + expect(screen.getByText('Instructions are required')).toBeDefined() + expect(screen.getByText('An Overpass query is required')).toBeDefined() + expect( + screen.getByText('You must read and accept the Automated Edits code of conduct') + ).toBeDefined() + expect(onSubmit).not.toHaveBeenCalled() + }) + + it('requires a GeoJSON URL when the remote GeoJSON data source is selected', async () => { + const user = userEvent.setup() + const { onSubmit } = renderCreateForm(undefined) + + await user.click(screen.getByRole('radio', { name: /I have a URL to the GeoJSON data/i })) + await user.click(screen.getByRole('button', { name: /create challenge/i })) + + expect(await screen.findByText('A GeoJSON URL is required')).toBeDefined() + expect(screen.queryByText('An Overpass query is required')).toBeNull() + expect(onSubmit).not.toHaveBeenCalled() + }) + + it('submits successfully once all required create-mode fields are provided', async () => { + const user = userEvent.setup() + const { onSubmit } = renderCreateForm(42) + + await user.type(screen.getByPlaceholderText(/Challenge/), 'My New Challenge') + await user.type( + screen.getByPlaceholderText('Describe what this challenge is about...'), + 'A description' + ) + await user.type( + screen.getByPlaceholderText('Instructions for completing tasks...'), + 'Some instructions' + ) + await user.type( + screen.getByPlaceholderText('[out:xml][timeout:25];(way[highway=primary];);out meta;'), + 'a valid overpass query' + ) + await user.click( + screen.getByRole('checkbox', { + name: /I have read and understand the OSM Automated Edits code of conduct/i, + }) + ) + await user.click(screen.getByRole('button', { name: /create challenge/i })) + + expect(onSubmit).toHaveBeenCalledWith( + expect.objectContaining({ + projectId: 42, + name: 'My New Challenge', + description: 'A description', + instruction: 'Some instructions', + dataSource: 'overpass', + overpassQL: 'a valid overpass query', + automatedEditsCodeAgreement: true, + }) + ) + }) +}) + +describe('ChallengeForm validation (edit mode)', () => { + const existingChallenge = { + id: 5, + parent: 10, + name: 'Existing Challenge', + description: 'Existing description', + instruction: 'Existing instructions', + difficulty: 2, + overpassQL: 'existing overpass query', + remoteGeoJson: '', + } as unknown as Challenge + + it('submits successfully without requiring project selection or the agreement checkbox', async () => { + const user = userEvent.setup() + const { onSubmit } = renderEditForm(existingChallenge) + + await user.click(screen.getByRole('button', { name: /update challenge/i })) + + expect(onSubmit).toHaveBeenCalledWith( + expect.objectContaining({ + projectId: 10, + name: 'Existing Challenge', + automatedEditsCodeAgreement: true, + }) + ) + }) + + it('does not render the automated edits agreement checkbox when editing', () => { + renderEditForm(existingChallenge) + + expect( + screen.queryByRole('checkbox', { + name: /I have read and understand the OSM Automated Edits code of conduct/i, + }) + ).toBeNull() + }) +}) diff --git a/src/components/Pages/ManagementPages/ManageProjectNew/ProjectForm.test.tsx b/src/components/Pages/ManagementPages/ManageProjectNew/ProjectForm.test.tsx new file mode 100644 index 000000000..67f832b57 --- /dev/null +++ b/src/components/Pages/ManagementPages/ManageProjectNew/ProjectForm.test.tsx @@ -0,0 +1,85 @@ +import { cleanup, render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { User } from '@/types/User' +import { ProjectForm } from './ProjectForm.tsx' + +const { useAuthContextMock } = vi.hoisted(() => ({ + useAuthContextMock: vi.fn(), +})) + +vi.mock('@/contexts/AuthContext', () => ({ + useAuthContext: useAuthContextMock, +})) + +const regularUser = { grants: [] } as unknown as User + +afterEach(() => { + cleanup() + vi.clearAllMocks() +}) + +const renderForm = () => { + const onSubmit = vi.fn().mockResolvedValue(undefined) + const onCancel = vi.fn() + render() + return { onSubmit, onCancel } +} + +describe('ProjectForm validation (projectFormSchema)', () => { + beforeEach(() => { + useAuthContextMock.mockReturnValue({ + user: regularUser, + isAuthenticated: true, + authLoading: false, + error: null, + login: vi.fn(), + logout: vi.fn(), + }) + }) + + it('shows a required-field error for an empty project name on submit', async () => { + const user = userEvent.setup() + const { onSubmit } = renderForm() + + await user.click(screen.getByRole('button', { name: /create project/i })) + + expect(await screen.findByText('Project name is required')).toBeDefined() + expect(onSubmit).not.toHaveBeenCalled() + }) + + it('shows a required-field error for an empty display name on submit', async () => { + const user = userEvent.setup() + const { onSubmit } = renderForm() + + await user.type(screen.getByPlaceholderText('my-project'), 'my-project') + await user.click(screen.getByRole('button', { name: /create project/i })) + + expect(await screen.findByText('Display name is required')).toBeDefined() + expect(onSubmit).not.toHaveBeenCalled() + }) + + it('submits successfully once required fields are filled in', async () => { + const user = userEvent.setup() + const { onSubmit } = renderForm() + + await user.type(screen.getByPlaceholderText('my-project'), 'my-project') + await user.type(screen.getByPlaceholderText('My Project'), 'My Project') + await user.click(screen.getByRole('button', { name: /create project/i })) + + await screen.findByRole('button', { name: /create project/i }) + expect(onSubmit).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'my-project', + displayName: 'My Project', + enabled: true, + featured: false, + }) + ) + }) + + it('does not render the featured toggle for a non-super-user', () => { + renderForm() + expect(screen.queryByText('Featured')).toBeNull() + }) +}) diff --git a/src/components/Pages/ManagementPages/ManageTaskEdit/TaskForm.test.tsx b/src/components/Pages/ManagementPages/ManageTaskEdit/TaskForm.test.tsx new file mode 100644 index 000000000..8402553e1 --- /dev/null +++ b/src/components/Pages/ManagementPages/ManageTaskEdit/TaskForm.test.tsx @@ -0,0 +1,85 @@ +import { cleanup, render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { TaskGetResponse } from '@/types/Task' +import { TaskForm } from './TaskForm.tsx' + +const baseTask = { + name: 'Fix the sidewalk', + instruction: 'Please fix the broken sidewalk segment', + geometries: { type: 'Point', coordinates: [0, 0] }, + status: 0, + errorTags: '', +} as unknown as TaskGetResponse + +afterEach(() => cleanup()) + +const renderForm = (task: TaskGetResponse = baseTask) => { + const onSubmit = vi.fn().mockResolvedValue(undefined) + const onCancel = vi.fn() + render() + return { onSubmit, onCancel } +} + +describe('TaskForm validation (taskFormSchema)', () => { + it('submits successfully with the default valid values', async () => { + const user = userEvent.setup() + const { onSubmit } = renderForm() + + await user.click(screen.getByRole('button', { name: /^save$/i })) + + expect(onSubmit).toHaveBeenCalledWith( + expect.objectContaining({ name: 'Fix the sidewalk', status: 0 }) + ) + }) + + it('shows an error when the name is shorter than 3 characters', async () => { + const user = userEvent.setup() + const { onSubmit } = renderForm() + + const nameInput = screen.getByPlaceholderText('Task name') + await user.clear(nameInput) + await user.type(nameInput, 'ab') + await user.click(screen.getByRole('button', { name: /^save$/i })) + + expect(await screen.findByText('Name must be at least 3 characters')).toBeDefined() + expect(onSubmit).not.toHaveBeenCalled() + }) + + it('accepts a name exactly 3 characters long', async () => { + const user = userEvent.setup() + const { onSubmit } = renderForm() + + const nameInput = screen.getByPlaceholderText('Task name') + await user.clear(nameInput) + await user.type(nameInput, 'abc') + await user.click(screen.getByRole('button', { name: /^save$/i })) + + expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ name: 'abc' })) + }) + + it('shows an error when the GeoJSON field is cleared', async () => { + const user = userEvent.setup() + const { onSubmit } = renderForm() + + const geometriesInput = screen.getByPlaceholderText('{"type":"Point","coordinates":[0,0]}') + await user.clear(geometriesInput) + await user.click(screen.getByRole('button', { name: /^save$/i })) + + expect(await screen.findByText('GeoJSON is required')).toBeDefined() + expect(onSubmit).not.toHaveBeenCalled() + }) + + it('allows the optional instruction and errorTags fields to be cleared', async () => { + const user = userEvent.setup() + const { onSubmit } = renderForm() + + const instructionInput = screen.getByPlaceholderText( + 'Instructions for this task (overrides challenge instructions)' + ) + await user.clear(instructionInput) + await user.click(screen.getByRole('button', { name: /^save$/i })) + + expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ instruction: '' })) + }) +}) diff --git a/src/components/Pages/SettingsPage/UserSettingsForm/formSchema.test.ts b/src/components/Pages/SettingsPage/UserSettingsForm/formSchema.test.ts new file mode 100644 index 000000000..831f4b842 --- /dev/null +++ b/src/components/Pages/SettingsPage/UserSettingsForm/formSchema.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, it } from 'vitest' +import { formSchema } from './formSchema.ts' + +const validInput = { + defaultEditor: 1, + defaultBasemap: 'MAPNIK', + defaultBasemapId: 'custom-basemap-id', + locale: 'en-US', + email: 'user@example.com', + emailOptIn: true, + leaderboardOptOut: false, + needsReview: 2, + isReviewer: true, + allowFollowing: true, + theme: 1, + seeTagFixSuggestions: true, + disableTaskConfirm: false, +} + +describe('formSchema', () => { + it('accepts a fully populated valid input', () => { + const result = formSchema.safeParse(validInput) + expect(result.success).toBe(true) + }) + + it('accepts the minimal input with only the required field set', () => { + const result = formSchema.safeParse({ defaultBasemap: -1 }) + expect(result.success).toBe(true) + }) + + it('rejects input missing the required defaultBasemap field', () => { + const { defaultBasemap: _defaultBasemap, ...rest } = validInput + const result = formSchema.safeParse(rest) + expect(result.success).toBe(false) + }) + + it('rejects a defaultEditor value not present in editorOptions', () => { + const result = formSchema.safeParse({ ...validInput, defaultEditor: 999 }) + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.issues.some((issue) => issue.message === 'Invalid editor option')).toBe( + true + ) + } + }) + + it('accepts every documented editor option value', () => { + for (const value of [-1, 0, 1, 2, 3, 4, 5]) { + const result = formSchema.safeParse({ ...validInput, defaultEditor: value }) + expect(result.success).toBe(true) + } + }) + + it('rejects a defaultBasemap value not present in baseMapOptions', () => { + const result = formSchema.safeParse({ ...validInput, defaultBasemap: 'NotARealBasemap' }) + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.issues.some((issue) => issue.message === 'Invalid basemap option')).toBe( + true + ) + } + }) + + it('accepts both numeric and string basemap option values', () => { + expect(formSchema.safeParse({ ...validInput, defaultBasemap: -1 }).success).toBe(true) + expect(formSchema.safeParse({ ...validInput, defaultBasemap: 'Bing' }).success).toBe(true) + }) + + it('rejects a locale value not present in localeOptions', () => { + const result = formSchema.safeParse({ ...validInput, locale: 'xx-XX' }) + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.issues.some((issue) => issue.message === 'Invalid language option')).toBe( + true + ) + } + }) + + it('omits the optional locale field without failing', () => { + const { locale: _locale, ...rest } = validInput + const result = formSchema.safeParse(rest) + expect(result.success).toBe(true) + }) + + it('accepts a well-formed email address', () => { + const result = formSchema.safeParse({ ...validInput, email: 'someone@maproulette.org' }) + expect(result.success).toBe(true) + }) + + it('accepts an empty string for email', () => { + const result = formSchema.safeParse({ ...validInput, email: '' }) + expect(result.success).toBe(true) + }) + + it('rejects a malformed email address', () => { + const result = formSchema.safeParse({ ...validInput, email: 'not-an-email' }) + expect(result.success).toBe(false) + }) + + it('rejects a negative needsReview value', () => { + const result = formSchema.safeParse({ ...validInput, needsReview: -1 }) + expect(result.success).toBe(false) + }) + + it('accepts a needsReview value of zero', () => { + const result = formSchema.safeParse({ ...validInput, needsReview: 0 }) + expect(result.success).toBe(true) + }) + + it('rejects a theme value below the minimum', () => { + const result = formSchema.safeParse({ ...validInput, theme: -1 }) + expect(result.success).toBe(false) + }) + + it('rejects a theme value above the maximum', () => { + const result = formSchema.safeParse({ ...validInput, theme: 3 }) + expect(result.success).toBe(false) + }) + + it('accepts theme values at the boundaries', () => { + expect(formSchema.safeParse({ ...validInput, theme: 0 }).success).toBe(true) + expect(formSchema.safeParse({ ...validInput, theme: 2 }).success).toBe(true) + }) + + it('rejects a non-boolean value for a boolean field', () => { + const result = formSchema.safeParse({ ...validInput, emailOptIn: 'yes' }) + expect(result.success).toBe(false) + }) +}) diff --git a/src/components/Pages/TaskEditPage/TaskMap/useAllMarkersMap.test.tsx b/src/components/Pages/TaskEditPage/TaskMap/useAllMarkersMap.test.tsx new file mode 100644 index 000000000..f48ded6db --- /dev/null +++ b/src/components/Pages/TaskEditPage/TaskMap/useAllMarkersMap.test.tsx @@ -0,0 +1,84 @@ +import { renderHook } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import type { TaskMarker } from '@/types/Task' +import { useAllMarkersMap } from './useAllMarkersMap' + +const makeMarker = (id: number): TaskMarker => ({ id }) as unknown as TaskMarker + +describe('useAllMarkersMap', () => { + it('returns an empty map when there are no markers or overlaps', () => { + const { result } = renderHook(() => useAllMarkersMap([], [])) + + expect(result.current.size).toBe(0) + }) + + it('indexes regular markers by id', () => { + const markers = [makeMarker(1), makeMarker(2)] + const { result } = renderHook(() => useAllMarkersMap(markers, [])) + + expect(result.current.size).toBe(2) + expect(result.current.get(1)).toBe(markers[0]) + expect(result.current.get(2)).toBe(markers[1]) + }) + + it('adds overlap tasks that are not already in the regular markers', () => { + const markers = [makeMarker(1)] + const overlapTask = makeMarker(2) + const overlaps = [{ tasks: [overlapTask] }] + + const { result } = renderHook(() => useAllMarkersMap(markers, overlaps)) + + expect(result.current.size).toBe(2) + expect(result.current.get(2)).toBe(overlapTask) + }) + + it('does not let an overlap task overwrite a regular marker with the same id', () => { + const regularMarker = makeMarker(1) + const overlapMarker = makeMarker(1) + const overlaps = [{ tasks: [overlapMarker] }] + + const { result } = renderHook(() => useAllMarkersMap([regularMarker], overlaps)) + + expect(result.current.size).toBe(1) + expect(result.current.get(1)).toBe(regularMarker) + }) + + it('merges tasks across multiple overlap groups without duplicating ids', () => { + const overlapA = { tasks: [makeMarker(2), makeMarker(3)] } + const overlapB = { tasks: [makeMarker(3), makeMarker(4)] } + + const { result } = renderHook(() => useAllMarkersMap([], [overlapA, overlapB])) + + expect(result.current.size).toBe(3) + expect([...result.current.keys()].sort()).toEqual([2, 3, 4]) + }) + + it('memoizes the map when markers and overlaps do not change', () => { + const markers = [makeMarker(1)] + const overlaps = [{ tasks: [makeMarker(2)] }] + + const { result, rerender } = renderHook( + ({ m, o }: { m: TaskMarker[]; o: { tasks: TaskMarker[] }[] }) => useAllMarkersMap(m, o), + { initialProps: { m: markers, o: overlaps } } + ) + const firstMap = result.current + + rerender({ m: markers, o: overlaps }) + + expect(result.current).toBe(firstMap) + }) + + it('recomputes the map when the markers array changes', () => { + const overlaps: { tasks: TaskMarker[] }[] = [] + const { result, rerender } = renderHook( + ({ m, o }: { m: TaskMarker[]; o: { tasks: TaskMarker[] }[] }) => useAllMarkersMap(m, o), + { initialProps: { m: [makeMarker(1)], o: overlaps } } + ) + const firstMap = result.current + + rerender({ m: [makeMarker(1), makeMarker(2)], o: overlaps }) + + expect(result.current).not.toBe(firstMap) + expect(result.current.size).toBe(2) + }) +}) diff --git a/src/components/Pages/TaskEditPage/TaskMap/useLassoBundleSync.test.tsx b/src/components/Pages/TaskEditPage/TaskMap/useLassoBundleSync.test.tsx new file mode 100644 index 000000000..1f829e7a1 --- /dev/null +++ b/src/components/Pages/TaskEditPage/TaskMap/useLassoBundleSync.test.tsx @@ -0,0 +1,186 @@ +import { renderHook } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { TaskBundle } from '@/components/Pages/TaskEditPage/contexts/TaskBundleContext' +import type { Task } from '@/types/Task' + +const { getTaskMock, useTaskBundleContextMock, useTaskContextMock, useTaskMapContextMock } = + vi.hoisted(() => ({ + getTaskMock: vi.fn(), + useTaskBundleContextMock: vi.fn(), + useTaskContextMock: vi.fn(), + useTaskMapContextMock: vi.fn(), + })) + +vi.mock('@/api', () => ({ + api: { task: { getTask: getTaskMock } }, +})) +vi.mock('@/components/Pages/TaskEditPage/contexts/TaskBundleContext', () => ({ + PENDING_BUNDLE_ID: 0, + useTaskBundleContext: useTaskBundleContextMock, +})) +vi.mock('@/components/Pages/TaskEditPage/contexts/TaskContext', () => ({ + useTaskContext: useTaskContextMock, +})) +vi.mock('@/components/Pages/TaskEditPage/contexts/TaskMapContext', () => ({ + MAX_SELECTED_TASKS: 50, + useTaskMapContext: useTaskMapContextMock, +})) + +import { useLassoBundleSync } from './useLassoBundleSync' + +const setContext = ({ + selectedTaskIds, + clearSelection = vi.fn(), + activeBundle = null, + setActiveBundle = vi.fn(), + primaryTaskId = 1, + primaryTaskData, +}: { + selectedTaskIds: Set + clearSelection?: () => void + activeBundle?: TaskBundle | null + setActiveBundle?: (v: TaskBundle | ((prev: TaskBundle | null) => TaskBundle | null)) => void + primaryTaskId?: number + primaryTaskData?: Task +}) => { + useTaskMapContextMock.mockReturnValue({ selectedTaskIds, clearSelection }) + useTaskBundleContextMock.mockReturnValue({ activeBundle, setActiveBundle }) + useTaskContextMock.mockReturnValue({ task: { id: primaryTaskId } as unknown as Task }) + getTaskMock.mockReturnValue({ data: primaryTaskData }) +} + +describe('useLassoBundleSync', () => { + beforeEach(() => { + getTaskMock.mockReset() + useTaskBundleContextMock.mockReset() + useTaskContextMock.mockReset() + useTaskMapContextMock.mockReset() + }) + + it('does nothing when there is no lasso selection', () => { + const clearSelection = vi.fn() + const setActiveBundle = vi.fn() + setContext({ selectedTaskIds: new Set(), clearSelection, setActiveBundle }) + + renderHook(() => useLassoBundleSync()) + + expect(setActiveBundle).not.toHaveBeenCalled() + expect(clearSelection).not.toHaveBeenCalled() + }) + + it('creates a pending bundle from the primary task and the selection when there is no active bundle', () => { + const clearSelection = vi.fn() + const setActiveBundle = vi.fn() + const primaryTaskData = { id: 1, name: 'primary' } as unknown as Task + setContext({ + selectedTaskIds: new Set([2, 3]), + clearSelection, + setActiveBundle, + primaryTaskId: 1, + primaryTaskData, + }) + + renderHook(() => useLassoBundleSync()) + + expect(setActiveBundle).toHaveBeenCalledWith({ + bundleId: 0, + taskIds: [1, 2, 3], + tasks: [primaryTaskData], + name: 'Bundle (pending)', + }) + expect(clearSelection).toHaveBeenCalledTimes(1) + }) + + it('creates a pending bundle with no tasks when the primary task data has not loaded yet', () => { + const setActiveBundle = vi.fn() + setContext({ + selectedTaskIds: new Set([2]), + setActiveBundle, + primaryTaskId: 1, + primaryTaskData: undefined, + }) + + renderHook(() => useLassoBundleSync()) + + expect(setActiveBundle).toHaveBeenCalledWith( + expect.objectContaining({ tasks: [], taskIds: [1, 2] }) + ) + }) + + it('adds newly-selected tasks to an existing bundle', () => { + const clearSelection = vi.fn() + const setActiveBundle = vi.fn() + const activeBundle: TaskBundle = { bundleId: 5, taskIds: [1, 2], name: 'Bundle 5' } + setContext({ + selectedTaskIds: new Set([3, 4]), + clearSelection, + setActiveBundle, + activeBundle, + primaryTaskId: 1, + }) + + renderHook(() => useLassoBundleSync()) + + expect(setActiveBundle).toHaveBeenCalledWith({ + ...activeBundle, + taskIds: [1, 2, 3, 4], + tasks: activeBundle.tasks, + }) + expect(clearSelection).toHaveBeenCalledTimes(1) + }) + + it('does not update the bundle when the selection only contains ids already in the bundle', () => { + const clearSelection = vi.fn() + const setActiveBundle = vi.fn() + const activeBundle: TaskBundle = { bundleId: 5, taskIds: [1, 2, 3], name: 'Bundle 5' } + setContext({ + selectedTaskIds: new Set([2, 3]), + clearSelection, + setActiveBundle, + activeBundle, + primaryTaskId: 1, + }) + + renderHook(() => useLassoBundleSync()) + + expect(setActiveBundle).not.toHaveBeenCalled() + expect(clearSelection).toHaveBeenCalledTimes(1) + }) + + it('truncates a newly-created bundle to MAX_SELECTED_TASKS', () => { + const setActiveBundle = vi.fn() + const manySelected = new Set(Array.from({ length: 60 }, (_, i) => i + 2)) + setContext({ + selectedTaskIds: manySelected, + setActiveBundle, + primaryTaskId: 1, + }) + + renderHook(() => useLassoBundleSync()) + + const created = setActiveBundle.mock.calls[0][0] as TaskBundle + expect(created.taskIds).toHaveLength(50) + expect(created.taskIds[0]).toBe(1) + }) + + it('truncates an updated bundle to MAX_SELECTED_TASKS', () => { + const setActiveBundle = vi.fn() + const activeBundle: TaskBundle = { + bundleId: 5, + taskIds: Array.from({ length: 45 }, (_, i) => i + 1), + name: 'Bundle 5', + } + const manySelected = new Set(Array.from({ length: 20 }, (_, i) => i + 1000)) + setContext({ + selectedTaskIds: manySelected, + setActiveBundle, + activeBundle, + primaryTaskId: 1, + }) + + renderHook(() => useLassoBundleSync()) + + const updated = setActiveBundle.mock.calls[0][0] as TaskBundle + expect(updated.taskIds).toHaveLength(50) + }) +}) diff --git a/src/components/Pages/TaskEditPage/TaskMap/useLassoEvents.test.tsx b/src/components/Pages/TaskEditPage/TaskMap/useLassoEvents.test.tsx new file mode 100644 index 000000000..a43054006 --- /dev/null +++ b/src/components/Pages/TaskEditPage/TaskMap/useLassoEvents.test.tsx @@ -0,0 +1,404 @@ +import { renderHook } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { TaskBundle } from '@/components/Pages/TaskEditPage/contexts/TaskBundleContext' +import type { LassoMode } from '@/components/Pages/TaskEditPage/contexts/TaskMapContext' +import type { Task, TaskMarker } from '@/types/Task' +import type { User } from '@/types/User' + +const { + getChallengeTaskMarkersMock, + useTaskBundleContextMock, + useTaskContextMock, + useTaskMapContextMock, + useAuthContextMock, +} = vi.hoisted(() => ({ + getChallengeTaskMarkersMock: vi.fn(), + useTaskBundleContextMock: vi.fn(), + useTaskContextMock: vi.fn(), + useTaskMapContextMock: vi.fn(), + useAuthContextMock: vi.fn(), +})) + +vi.mock('@/api', () => ({ + api: { challenge: { getChallengeTaskMarkers: getChallengeTaskMarkersMock } }, +})) +vi.mock('@/components/Pages/TaskEditPage/contexts/TaskBundleContext', () => ({ + useTaskBundleContext: useTaskBundleContextMock, +})) +vi.mock('@/components/Pages/TaskEditPage/contexts/TaskContext', () => ({ + useTaskContext: useTaskContextMock, +})) +vi.mock('@/components/Pages/TaskEditPage/contexts/TaskMapContext', () => ({ + MAX_SELECTED_TASKS: 50, + useTaskMapContext: useTaskMapContextMock, +})) +vi.mock('@/contexts/AuthContext', () => ({ + useAuthContext: useAuthContextMock, +})) + +import { useLassoEvents } from './useLassoEvents' + +const makeMarker = ( + id: number, + lng: number, + lat: number, + overrides: Partial = {} +): TaskMarker => + ({ + id, + location: { lng, lat }, + status: 0, + bundleId: null, + lockedBy: null, + ...overrides, + }) as unknown as TaskMarker + +const makeCanvasMap = () => { + const canvas = document.createElement('canvas') + vi.spyOn(canvas, 'getBoundingClientRect').mockReturnValue({ + left: 0, + top: 0, + right: 0, + bottom: 0, + width: 0, + height: 0, + x: 0, + y: 0, + toJSON: () => ({}), + } as DOMRect) + + const bounds = { + getWest: () => -1000, + getEast: () => 1000, + getSouth: () => -1000, + getNorth: () => 1000, + } + + return { + canvas, + getCanvas: () => canvas, + unproject: ([x, y]: [number, number]) => ({ lng: x, lat: y }), + project: (p: [number, number] | { lng: number; lat: number }) => { + const [lng, lat] = Array.isArray(p) ? p : [p.lng, p.lat] + return { x: lng, y: lat } + }, + getBounds: () => bounds, + dragPan: { enable: vi.fn(), disable: vi.fn() }, + } +} + +type FakeMap = ReturnType + +interface Overrides { + drawingMode?: LassoMode + setDrawingMode?: (m: LassoMode) => void + setIsDrawing?: (v: boolean) => void + setLassoPolygon?: (p: [number, number][] | null) => void + setSelectedTaskIds?: (updater: (prev: Set) => Set) => void + cancelDrawing?: () => void + taskId?: number + challengeId?: number + taskBundleId?: number | null + activeBundle?: TaskBundle | null + userId?: number | null + markers?: TaskMarker[] + mapRefCurrent?: FakeMap | null +} + +const setContext = ({ + drawingMode = null, + setDrawingMode = vi.fn(), + setIsDrawing = vi.fn(), + setLassoPolygon = vi.fn(), + setSelectedTaskIds = vi.fn(), + cancelDrawing = vi.fn(), + taskId = 1, + challengeId = 100, + taskBundleId = null, + activeBundle = null, + userId = 1, + markers = [], + mapRefCurrent, +}: Overrides) => { + useTaskMapContextMock.mockReturnValue({ + map: { current: mapRefCurrent === undefined ? null : mapRefCurrent }, + drawingMode, + setDrawingMode, + setIsDrawing, + setLassoPolygon, + setSelectedTaskIds, + cancelDrawing, + }) + useTaskContextMock.mockReturnValue({ + task: { id: taskId, parent: challengeId, bundleId: taskBundleId } as unknown as Task, + }) + useTaskBundleContextMock.mockReturnValue({ activeBundle }) + useAuthContextMock.mockReturnValue({ + user: userId == null ? undefined : ({ id: userId } as User), + }) + getChallengeTaskMarkersMock.mockReturnValue({ data: { markers, overlaps: [] } }) +} + +const mapRefWithFakeMap = (fakeMap: FakeMap) => ({ getMap: () => fakeMap }) + +describe('useLassoEvents', () => { + beforeEach(() => { + getChallengeTaskMarkersMock.mockReset() + useTaskBundleContextMock.mockReset() + useTaskContextMock.mockReset() + useTaskMapContextMock.mockReset() + useAuthContextMock.mockReset() + }) + + it('does not attach mouse listeners when not in drawing mode', () => { + const fakeMap = makeCanvasMap() + const setIsDrawing = vi.fn() + setContext({ + drawingMode: null, + setIsDrawing, + mapRefCurrent: mapRefWithFakeMap(fakeMap) as unknown as FakeMap, + }) + + renderHook(() => useLassoEvents()) + + fakeMap.canvas.dispatchEvent(new MouseEvent('mousedown', { button: 0 })) + + expect(setIsDrawing).not.toHaveBeenCalled() + }) + + it('starts a lasso on mousedown while in select mode, recording the first point and disabling dragPan', () => { + const fakeMap = makeCanvasMap() + const setIsDrawing = vi.fn() + const setLassoPolygon = vi.fn() + setContext({ + drawingMode: 'select', + setIsDrawing, + setLassoPolygon, + mapRefCurrent: mapRefWithFakeMap(fakeMap) as unknown as FakeMap, + }) + + renderHook(() => useLassoEvents()) + + fakeMap.canvas.dispatchEvent(new MouseEvent('mousedown', { button: 0, clientX: 0, clientY: 0 })) + + expect(setLassoPolygon).toHaveBeenCalledWith([[0, 0]]) + expect(setIsDrawing).toHaveBeenCalledWith(true) + expect(fakeMap.dragPan.disable).toHaveBeenCalledTimes(1) + }) + + it('ignores non-primary mouse buttons on mousedown', () => { + const fakeMap = makeCanvasMap() + const setIsDrawing = vi.fn() + setContext({ + drawingMode: 'select', + setIsDrawing, + mapRefCurrent: mapRefWithFakeMap(fakeMap) as unknown as FakeMap, + }) + + renderHook(() => useLassoEvents()) + + fakeMap.canvas.dispatchEvent(new MouseEvent('mousedown', { button: 2, clientX: 0, clientY: 0 })) + + expect(setIsDrawing).not.toHaveBeenCalled() + }) + + it('accumulates polygon points as the mouse moves at least 3px between samples', () => { + const fakeMap = makeCanvasMap() + const setLassoPolygon = vi.fn() + setContext({ + drawingMode: 'select', + setLassoPolygon, + mapRefCurrent: mapRefWithFakeMap(fakeMap) as unknown as FakeMap, + }) + + renderHook(() => useLassoEvents()) + + fakeMap.canvas.dispatchEvent(new MouseEvent('mousedown', { button: 0, clientX: 0, clientY: 0 })) + window.dispatchEvent(new MouseEvent('mousemove', { clientX: 0, clientY: 10 })) + window.dispatchEvent(new MouseEvent('mousemove', { clientX: 1, clientY: 10 })) + + const calls = setLassoPolygon.mock.calls.map((c) => c[0]) + expect(calls).toContainEqual([ + [0, 0], + [0, 10], + ]) + expect(calls.at(-1)).toEqual([ + [0, 0], + [0, 10], + ]) + }) + + it('selects eligible, non-excluded tasks inside the drawn polygon on mouseup in select mode', () => { + const fakeMap = makeCanvasMap() + const setSelectedTaskIds = vi.fn() + const setIsDrawing = vi.fn() + const setLassoPolygon = vi.fn() + const setDrawingMode = vi.fn() + + const markers = [ + makeMarker(1, 5, 5), + makeMarker(2, 50, 50), + makeMarker(3, 5, 5, { status: 4 }), + makeMarker(4, 5, 5, { lockedBy: 999 }), + makeMarker(5, 5, 5), + makeMarker(6, 5, 5), + ] + + setContext({ + drawingMode: 'select', + setSelectedTaskIds, + setIsDrawing, + setLassoPolygon, + setDrawingMode, + taskId: 5, + taskBundleId: null, + userId: 1, + activeBundle: { bundleId: 9, taskIds: [6], name: 'b' }, + markers, + mapRefCurrent: mapRefWithFakeMap(fakeMap) as unknown as FakeMap, + }) + + renderHook(() => useLassoEvents()) + + fakeMap.canvas.dispatchEvent(new MouseEvent('mousedown', { button: 0, clientX: 0, clientY: 0 })) + window.dispatchEvent(new MouseEvent('mousemove', { clientX: 0, clientY: 10 })) + window.dispatchEvent(new MouseEvent('mousemove', { clientX: 10, clientY: 10 })) + window.dispatchEvent(new MouseEvent('mousemove', { clientX: 10, clientY: 0 })) + window.dispatchEvent(new MouseEvent('mouseup')) + + expect(fakeMap.dragPan.enable).toHaveBeenCalledTimes(1) + expect(setSelectedTaskIds).toHaveBeenCalledTimes(1) + const updater = setSelectedTaskIds.mock.calls[0][0] as (prev: Set) => Set + const result = updater(new Set()) + + expect([...result].sort()).toEqual([1]) + expect(setLassoPolygon).toHaveBeenLastCalledWith(null) + expect(setIsDrawing).toHaveBeenLastCalledWith(false) + expect(setDrawingMode).toHaveBeenCalledWith(null) + }) + + it('stops adding tasks once the selection hits MAX_SELECTED_TASKS', () => { + const fakeMap = makeCanvasMap() + const setSelectedTaskIds = vi.fn() + const markers = [makeMarker(1, 5, 5)] + + setContext({ + drawingMode: 'select', + setSelectedTaskIds, + taskId: 999, + markers, + mapRefCurrent: mapRefWithFakeMap(fakeMap) as unknown as FakeMap, + }) + + renderHook(() => useLassoEvents()) + + fakeMap.canvas.dispatchEvent(new MouseEvent('mousedown', { button: 0, clientX: 0, clientY: 0 })) + window.dispatchEvent(new MouseEvent('mousemove', { clientX: 0, clientY: 10 })) + window.dispatchEvent(new MouseEvent('mousemove', { clientX: 10, clientY: 10 })) + window.dispatchEvent(new MouseEvent('mousemove', { clientX: 10, clientY: 0 })) + window.dispatchEvent(new MouseEvent('mouseup')) + + const updater = setSelectedTaskIds.mock.calls[0][0] as (prev: Set) => Set + const alreadyFull = new Set(Array.from({ length: 50 }, (_, i) => i + 1000)) + const result = updater(alreadyFull) + + expect(result.size).toBe(50) + expect(result.has(1)).toBe(false) + }) + + it('removes tasks inside the polygon from the selection in deselect mode', () => { + const fakeMap = makeCanvasMap() + const setSelectedTaskIds = vi.fn() + const markers = [makeMarker(1, 5, 5), makeMarker(2, 50, 50)] + + setContext({ + drawingMode: 'deselect', + setSelectedTaskIds, + taskId: 999, + markers, + mapRefCurrent: mapRefWithFakeMap(fakeMap) as unknown as FakeMap, + }) + + renderHook(() => useLassoEvents()) + + fakeMap.canvas.dispatchEvent(new MouseEvent('mousedown', { button: 0, clientX: 0, clientY: 0 })) + window.dispatchEvent(new MouseEvent('mousemove', { clientX: 0, clientY: 10 })) + window.dispatchEvent(new MouseEvent('mousemove', { clientX: 10, clientY: 10 })) + window.dispatchEvent(new MouseEvent('mousemove', { clientX: 10, clientY: 0 })) + window.dispatchEvent(new MouseEvent('mouseup')) + + const updater = setSelectedTaskIds.mock.calls[0][0] as (prev: Set) => Set + const result = updater(new Set([1, 2])) + + expect([...result]).toEqual([2]) + }) + + it('discards the drawing without selecting when fewer than 3 points were drawn', () => { + const fakeMap = makeCanvasMap() + const setSelectedTaskIds = vi.fn() + setContext({ + drawingMode: 'select', + setSelectedTaskIds, + markers: [makeMarker(1, 5, 5)], + mapRefCurrent: mapRefWithFakeMap(fakeMap) as unknown as FakeMap, + }) + + renderHook(() => useLassoEvents()) + + fakeMap.canvas.dispatchEvent(new MouseEvent('mousedown', { button: 0, clientX: 0, clientY: 0 })) + window.dispatchEvent(new MouseEvent('mouseup')) + + expect(setSelectedTaskIds).not.toHaveBeenCalled() + }) + + it('cancels drawing on Escape while a drawing mode is active', () => { + const fakeMap = makeCanvasMap() + const cancelDrawing = vi.fn() + setContext({ + drawingMode: 'select', + cancelDrawing, + mapRefCurrent: mapRefWithFakeMap(fakeMap) as unknown as FakeMap, + }) + + renderHook(() => useLassoEvents()) + + window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })) + + expect(cancelDrawing).toHaveBeenCalledTimes(1) + }) + + it('does not cancel drawing on Escape when no drawing mode is active', () => { + const fakeMap = makeCanvasMap() + const cancelDrawing = vi.fn() + setContext({ + drawingMode: null, + cancelDrawing, + mapRefCurrent: mapRefWithFakeMap(fakeMap) as unknown as FakeMap, + }) + + renderHook(() => useLassoEvents()) + + window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })) + + expect(cancelDrawing).not.toHaveBeenCalled() + }) + + it('removes its event listeners on unmount', () => { + const fakeMap = makeCanvasMap() + const setIsDrawing = vi.fn() + setContext({ + drawingMode: 'select', + setIsDrawing, + mapRefCurrent: mapRefWithFakeMap(fakeMap) as unknown as FakeMap, + }) + + const { unmount } = renderHook(() => useLassoEvents()) + unmount() + + fakeMap.canvas.dispatchEvent(new MouseEvent('mousedown', { button: 0, clientX: 0, clientY: 0 })) + window.dispatchEvent(new MouseEvent('mousemove', { clientX: 5, clientY: 5 })) + window.dispatchEvent(new MouseEvent('mouseup')) + + expect(setIsDrawing).not.toHaveBeenCalled() + }) +}) diff --git a/src/components/Pages/TaskEditPage/TaskMap/useMapControlButtons.test.tsx b/src/components/Pages/TaskEditPage/TaskMap/useMapControlButtons.test.tsx new file mode 100644 index 000000000..4b0a838cf --- /dev/null +++ b/src/components/Pages/TaskEditPage/TaskMap/useMapControlButtons.test.tsx @@ -0,0 +1,222 @@ +import { renderHook } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { TaskBundle } from '@/components/Pages/TaskEditPage/contexts/TaskBundleContext' + +const { useTaskBundleContextMock, useTaskMapContextMock, useTaskEditMapContextMock } = vi.hoisted( + () => ({ + useTaskBundleContextMock: vi.fn(), + useTaskMapContextMock: vi.fn(), + useTaskEditMapContextMock: vi.fn(), + }) +) + +vi.mock('@/components/Pages/TaskEditPage/contexts/TaskBundleContext', () => ({ + useTaskBundleContext: useTaskBundleContextMock, +})) +vi.mock('@/components/Pages/TaskEditPage/contexts/TaskMapContext', () => ({ + useTaskMapContext: useTaskMapContextMock, +})) +vi.mock('@/components/Pages/TaskEditPage/TaskMap/TaskEditMapContext', () => ({ + useTaskEditMapContext: useTaskEditMapContextMock, +})) + +import { useMapControlButtons } from './useMapControlButtons' + +interface Overrides { + markersHidden?: boolean + setMarkersHidden?: (hidden: boolean) => void + activeBundle?: TaskBundle | null + showBundleOnly?: boolean + setShowBundleOnly?: (show: boolean) => void + showExploreLayer?: boolean + setShowExploreLayer?: (show: boolean) => void +} + +const setContext = ({ + markersHidden = false, + setMarkersHidden = vi.fn(), + activeBundle = null, + showBundleOnly = false, + setShowBundleOnly = vi.fn(), + showExploreLayer = false, + setShowExploreLayer = vi.fn(), +}: Overrides) => { + useTaskMapContextMock.mockReturnValue({ markersHidden, setMarkersHidden }) + useTaskBundleContextMock.mockReturnValue({ activeBundle, showBundleOnly, setShowBundleOnly }) + useTaskEditMapContextMock.mockReturnValue({ showExploreLayer, setShowExploreLayer }) +} + +describe('useMapControlButtons', () => { + beforeEach(() => { + useTaskBundleContextMock.mockReset() + useTaskMapContextMock.mockReset() + useTaskEditMapContextMock.mockReset() + }) + + it('returns the four control buttons in order', () => { + setContext({}) + const handleCenterToTask = vi.fn() + + const { result } = renderHook(() => useMapControlButtons(true, handleCenterToTask)) + + expect(result.current.map((b) => b.id)).toEqual([ + 'center-to-task', + 'toggle-markers', + 'toggle-bundle-only', + 'toggle-explore-layer', + ]) + }) + + it('disables every button when the map has not loaded', () => { + setContext({}) + + const { result } = renderHook(() => useMapControlButtons(false, vi.fn())) + + expect(result.current.every((b) => b.disabled)).toBe(true) + }) + + it('enables every button when the map has loaded', () => { + setContext({}) + + const { result } = renderHook(() => useMapControlButtons(true, vi.fn())) + + expect(result.current.every((b) => !b.disabled)).toBe(true) + }) + + it('center-to-task calls the provided handler and labels for a single task', () => { + setContext({ activeBundle: null }) + const handleCenterToTask = vi.fn() + + const { result } = renderHook(() => useMapControlButtons(true, handleCenterToTask)) + const button = result.current.find((b) => b.id === 'center-to-task') + + expect(button?.tooltip).toBe('Center to Task') + button?.onClick() + expect(handleCenterToTask).toHaveBeenCalledTimes(1) + }) + + it('center-to-task labels for a multi-task bundle', () => { + setContext({ + activeBundle: { bundleId: 1, taskIds: [1, 2], name: 'b' } as TaskBundle, + }) + + const { result } = renderHook(() => useMapControlButtons(true, vi.fn())) + const button = result.current.find((b) => b.id === 'center-to-task') + + expect(button?.tooltip).toBe('Center to Bundle') + }) + + it('center-to-task keeps single-task label for a bundle with only the primary task', () => { + setContext({ + activeBundle: { bundleId: 1, taskIds: [1], name: 'b' } as TaskBundle, + }) + + const { result } = renderHook(() => useMapControlButtons(true, vi.fn())) + const button = result.current.find((b) => b.id === 'center-to-task') + + expect(button?.tooltip).toBe('Center to Task') + }) + + it('toggle-markers reflects hidden state and toggles it on click', () => { + const setMarkersHidden = vi.fn() + setContext({ markersHidden: true, setMarkersHidden }) + + const { result } = renderHook(() => useMapControlButtons(true, vi.fn())) + const button = result.current.find((b) => b.id === 'toggle-markers') + + expect(button?.tooltip).toBe('Show all markers') + expect(button?.isActive).toBe(true) + button?.onClick() + expect(setMarkersHidden).toHaveBeenCalledWith(false) + }) + + it('toggle-markers reflects visible state and toggles it on click', () => { + const setMarkersHidden = vi.fn() + setContext({ markersHidden: false, setMarkersHidden }) + + const { result } = renderHook(() => useMapControlButtons(true, vi.fn())) + const button = result.current.find((b) => b.id === 'toggle-markers') + + expect(button?.tooltip).toBe('Hide all markers') + expect(button?.isActive).toBe(false) + button?.onClick() + expect(setMarkersHidden).toHaveBeenCalledWith(true) + }) + + it('toggle-bundle-only shows "show all" tooltip when filtered on', () => { + setContext({ showBundleOnly: true }) + + const { result } = renderHook(() => useMapControlButtons(true, vi.fn())) + const button = result.current.find((b) => b.id === 'toggle-bundle-only') + + expect(button?.tooltip).toBe('Show all tasks (F)') + expect(button?.isActive).toBe(true) + }) + + it('toggle-bundle-only shows "selected tasks" tooltip when a bundle is active and unfiltered', () => { + setContext({ + showBundleOnly: false, + activeBundle: { bundleId: 1, taskIds: [1, 2], name: 'b' } as TaskBundle, + }) + + const { result } = renderHook(() => useMapControlButtons(true, vi.fn())) + const button = result.current.find((b) => b.id === 'toggle-bundle-only') + + expect(button?.tooltip).toBe('Show selected tasks only (F)') + }) + + it('toggle-bundle-only shows "primary task" tooltip with no active bundle and unfiltered', () => { + setContext({ showBundleOnly: false, activeBundle: null }) + + const { result } = renderHook(() => useMapControlButtons(true, vi.fn())) + const button = result.current.find((b) => b.id === 'toggle-bundle-only') + + expect(button?.tooltip).toBe('Show primary task only (F)') + }) + + it('toggle-bundle-only toggles showBundleOnly on click', () => { + const setShowBundleOnly = vi.fn() + setContext({ showBundleOnly: false, setShowBundleOnly }) + + const { result } = renderHook(() => useMapControlButtons(true, vi.fn())) + const button = result.current.find((b) => b.id === 'toggle-bundle-only') + button?.onClick() + + expect(setShowBundleOnly).toHaveBeenCalledWith(true) + }) + + it('toggle-explore-layer reflects and toggles showExploreLayer', () => { + const setShowExploreLayer = vi.fn() + setContext({ showExploreLayer: false, setShowExploreLayer }) + + const { result } = renderHook(() => useMapControlButtons(true, vi.fn())) + const button = result.current.find((b) => b.id === 'toggle-explore-layer') + + expect(button?.tooltip).toBe('Show tasks from other challenges') + expect(button?.isActive).toBe(false) + button?.onClick() + expect(setShowExploreLayer).toHaveBeenCalledWith(true) + }) + + it('toggle-explore-layer shows hide tooltip when already showing', () => { + setContext({ showExploreLayer: true }) + + const { result } = renderHook(() => useMapControlButtons(true, vi.fn())) + const button = result.current.find((b) => b.id === 'toggle-explore-layer') + + expect(button?.tooltip).toBe('Hide tasks from other challenges') + expect(button?.isActive).toBe(true) + }) + + it('memoizes the returned array when nothing relevant changes', () => { + setContext({}) + const handleCenterToTask = vi.fn() + + const { result, rerender } = renderHook(() => useMapControlButtons(true, handleCenterToTask)) + const first = result.current + + rerender() + + expect(result.current).toBe(first) + }) +}) diff --git a/src/components/Pages/TaskEditPage/TaskMap/useMapNavigation.test.tsx b/src/components/Pages/TaskEditPage/TaskMap/useMapNavigation.test.tsx new file mode 100644 index 000000000..51fc7503c --- /dev/null +++ b/src/components/Pages/TaskEditPage/TaskMap/useMapNavigation.test.tsx @@ -0,0 +1,162 @@ +import { renderHook } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { TaskBundle } from '@/components/Pages/TaskEditPage/contexts/TaskBundleContext' +import type { Task, TaskMarker } from '@/types/Task' + +const { useTaskBundleContextMock, useTaskContextMock, useTaskMapContextMock } = vi.hoisted(() => ({ + useTaskBundleContextMock: vi.fn(), + useTaskContextMock: vi.fn(), + useTaskMapContextMock: vi.fn(), +})) + +vi.mock('@/components/Pages/TaskEditPage/contexts/TaskBundleContext', () => ({ + useTaskBundleContext: useTaskBundleContextMock, +})) +vi.mock('@/components/Pages/TaskEditPage/contexts/TaskContext', () => ({ + useTaskContext: useTaskContextMock, +})) +vi.mock('@/components/Pages/TaskEditPage/contexts/TaskMapContext', () => ({ + useTaskMapContext: useTaskMapContextMock, +})) + +import { useMapNavigation } from './useMapNavigation' + +const makeMarker = (id: number, lng: number, lat: number): TaskMarker => + ({ id, location: { lng, lat } }) as unknown as TaskMarker + +const makeFakeMap = () => ({ + jumpTo: vi.fn(), + fitBounds: vi.fn(), +}) + +const setContext = ( + map: { current: ReturnType | null }, + taskId: number, + activeBundle: TaskBundle | null +) => { + useTaskMapContextMock.mockReturnValue({ map }) + useTaskContextMock.mockReturnValue({ task: { id: taskId } as unknown as Task }) + useTaskBundleContextMock.mockReturnValue({ activeBundle }) +} + +describe('useMapNavigation', () => { + beforeEach(() => { + useTaskBundleContextMock.mockReset() + useTaskContextMock.mockReset() + useTaskMapContextMock.mockReset() + }) + + it('zooms to the primary task marker once the map has loaded and markers are present', () => { + const fakeMap = makeFakeMap() + setContext({ current: fakeMap }, 1, null) + const markers = [makeMarker(1, 10, 20), makeMarker(2, 30, 40)] + + renderHook(() => useMapNavigation(true, markers, new Map())) + + expect(fakeMap.jumpTo).toHaveBeenCalledWith({ center: [10, 20], zoom: 16 }) + }) + + it('does not zoom when the map has not loaded', () => { + const fakeMap = makeFakeMap() + setContext({ current: fakeMap }, 1, null) + const markers = [makeMarker(1, 10, 20)] + + renderHook(() => useMapNavigation(false, markers, new Map())) + + expect(fakeMap.jumpTo).not.toHaveBeenCalled() + }) + + it('does not zoom when there are no markers', () => { + const fakeMap = makeFakeMap() + setContext({ current: fakeMap }, 1, null) + + renderHook(() => useMapNavigation(true, [], new Map())) + + expect(fakeMap.jumpTo).not.toHaveBeenCalled() + }) + + it('does not re-zoom to the same task on subsequent renders', () => { + const fakeMap = makeFakeMap() + setContext({ current: fakeMap }, 1, null) + const markers = [makeMarker(1, 10, 20)] + + const { rerender } = renderHook(() => useMapNavigation(true, markers, new Map())) + expect(fakeMap.jumpTo).toHaveBeenCalledTimes(1) + + rerender() + + expect(fakeMap.jumpTo).toHaveBeenCalledTimes(1) + }) + + it('zooms again when the primary task id changes', () => { + const fakeMap = makeFakeMap() + setContext({ current: fakeMap }, 1, null) + const markers = [makeMarker(1, 10, 20), makeMarker(2, 30, 40)] + + const { rerender } = renderHook(() => useMapNavigation(true, markers, new Map())) + expect(fakeMap.jumpTo).toHaveBeenCalledTimes(1) + + setContext({ current: fakeMap }, 2, null) + rerender() + + expect(fakeMap.jumpTo).toHaveBeenCalledTimes(2) + expect(fakeMap.jumpTo).toHaveBeenLastCalledWith({ center: [30, 40], zoom: 16 }) + }) + + it('handleCenterToTask centers on the primary task when there is no multi-task bundle', () => { + const fakeMap = makeFakeMap() + setContext({ current: fakeMap }, 1, null) + const markers = [makeMarker(1, 10, 20)] + + const { result } = renderHook(() => useMapNavigation(true, markers, new Map())) + fakeMap.jumpTo.mockClear() + + result.current.handleCenterToTask() + + expect(fakeMap.jumpTo).toHaveBeenCalledWith({ center: [10, 20], zoom: 16 }) + expect(fakeMap.fitBounds).not.toHaveBeenCalled() + }) + + it('handleCenterToTask fits bounds to the bundle when the bundle has multiple tasks', () => { + const fakeMap = makeFakeMap() + const activeBundle: TaskBundle = { bundleId: 1, taskIds: [1, 2], name: 'b' } + setContext({ current: fakeMap }, 1, activeBundle) + const markers = [makeMarker(1, 10, 20), makeMarker(2, 30, 40)] + const allMarkersMap = new Map(markers.map((m) => [m.id, m])) + + const { result } = renderHook(() => useMapNavigation(true, markers, allMarkersMap)) + fakeMap.jumpTo.mockClear() + + result.current.handleCenterToTask() + + expect(fakeMap.fitBounds).toHaveBeenCalledTimes(1) + const [bbox, options] = fakeMap.fitBounds.mock.calls[0] + expect(bbox).toEqual([10, 20, 30, 40]) + expect(options).toEqual({ padding: 80, duration: 0, maxZoom: 16 }) + expect(fakeMap.jumpTo).not.toHaveBeenCalled() + }) + + it('handleCenterToTask falls back to the primary task when bundle markers are not resolvable', () => { + const fakeMap = makeFakeMap() + const activeBundle: TaskBundle = { bundleId: 1, taskIds: [1, 2], name: 'b' } + setContext({ current: fakeMap }, 1, activeBundle) + const markers = [makeMarker(1, 10, 20)] + + const { result } = renderHook(() => useMapNavigation(true, markers, new Map())) + fakeMap.jumpTo.mockClear() + + result.current.handleCenterToTask() + + expect(fakeMap.fitBounds).not.toHaveBeenCalled() + expect(fakeMap.jumpTo).toHaveBeenCalledWith({ center: [10, 20], zoom: 16 }) + }) + + it('handleCenterToTask does nothing when the map is not yet available', () => { + setContext({ current: null }, 1, null) + const markers = [makeMarker(1, 10, 20)] + + const { result } = renderHook(() => useMapNavigation(true, markers, new Map())) + + expect(() => result.current.handleCenterToTask()).not.toThrow() + }) +}) diff --git a/src/components/Pages/TaskEditPage/TaskMap/useMarkerVisibility.test.tsx b/src/components/Pages/TaskEditPage/TaskMap/useMarkerVisibility.test.tsx new file mode 100644 index 000000000..3f6ae605f --- /dev/null +++ b/src/components/Pages/TaskEditPage/TaskMap/useMarkerVisibility.test.tsx @@ -0,0 +1,97 @@ +import { renderHook } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { TaskMarker } from '@/types/Task' + +const { useTaskMapContextMock } = vi.hoisted(() => ({ + useTaskMapContextMock: vi.fn(), +})) + +vi.mock('@/components/Pages/TaskEditPage/contexts/TaskMapContext', () => ({ + useTaskMapContext: useTaskMapContextMock, +})) + +import { useMarkerVisibility } from './useMarkerVisibility' + +const marker = { id: 1 } as unknown as TaskMarker + +interface ContextOverrides { + selectedMarker?: TaskMarker | null + markersHidden?: boolean + setMarkersHidden?: (hidden: boolean) => void +} + +const setContext = ({ + selectedMarker = null, + markersHidden = false, + setMarkersHidden = vi.fn(), +}: ContextOverrides) => { + useTaskMapContextMock.mockReturnValue({ selectedMarker, markersHidden, setMarkersHidden }) +} + +describe('useMarkerVisibility', () => { + beforeEach(() => { + useTaskMapContextMock.mockReset() + }) + + it('does nothing on initial mount when there is no selected marker', () => { + const setMarkersHidden = vi.fn() + setContext({ selectedMarker: null, markersHidden: false, setMarkersHidden }) + + renderHook(() => useMarkerVisibility()) + + expect(setMarkersHidden).not.toHaveBeenCalled() + }) + + it('does not reset markersHidden while a marker remains selected', () => { + const setMarkersHidden = vi.fn() + setContext({ selectedMarker: marker, markersHidden: true, setMarkersHidden }) + + const { rerender } = renderHook(() => useMarkerVisibility()) + + setContext({ + selectedMarker: { ...marker, id: 2 } as unknown as TaskMarker, + markersHidden: true, + setMarkersHidden, + }) + rerender() + + expect(setMarkersHidden).not.toHaveBeenCalled() + }) + + it('resets markersHidden to false when the selected marker is cleared while markers are hidden', () => { + const setMarkersHidden = vi.fn() + setContext({ selectedMarker: marker, markersHidden: true, setMarkersHidden }) + + const { rerender } = renderHook(() => useMarkerVisibility()) + + setContext({ selectedMarker: null, markersHidden: true, setMarkersHidden }) + rerender() + + expect(setMarkersHidden).toHaveBeenCalledWith(false) + expect(setMarkersHidden).toHaveBeenCalledTimes(1) + }) + + it('does not call setMarkersHidden when the selected marker is cleared but markers are already visible', () => { + const setMarkersHidden = vi.fn() + setContext({ selectedMarker: marker, markersHidden: false, setMarkersHidden }) + + const { rerender } = renderHook(() => useMarkerVisibility()) + + setContext({ selectedMarker: null, markersHidden: false, setMarkersHidden }) + rerender() + + expect(setMarkersHidden).not.toHaveBeenCalled() + }) + + it('does not fire when selectedMarker stays null across renders', () => { + const setMarkersHidden = vi.fn() + setContext({ selectedMarker: null, markersHidden: true, setMarkersHidden }) + + const { rerender } = renderHook(() => useMarkerVisibility()) + + setContext({ selectedMarker: null, markersHidden: true, setMarkersHidden }) + rerender() + + expect(setMarkersHidden).not.toHaveBeenCalled() + }) +}) diff --git a/src/components/Pages/TaskEditPage/TaskMap/useStyledClusteredData.test.tsx b/src/components/Pages/TaskEditPage/TaskMap/useStyledClusteredData.test.tsx new file mode 100644 index 000000000..7f6b76847 --- /dev/null +++ b/src/components/Pages/TaskEditPage/TaskMap/useStyledClusteredData.test.tsx @@ -0,0 +1,144 @@ +import { renderHook } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { useTaskMapContextMock } = vi.hoisted(() => ({ + useTaskMapContextMock: vi.fn(), +})) + +vi.mock('@/components/Pages/TaskEditPage/contexts/TaskMapContext', () => ({ + useTaskMapContext: useTaskMapContextMock, +})) + +import { useStyledClusteredData } from './useStyledClusteredData' + +const setContext = (selectedTaskIds: Set, activeTaskId: number | null) => { + useTaskMapContextMock.mockReturnValue({ selectedTaskIds, activeTaskId }) +} + +const clusterFeature: GeoJSON.Feature = { + type: 'Feature', + properties: { point_count: 3, cluster: true }, + geometry: { type: 'Point', coordinates: [1, 1] }, +} + +const featureWithoutId: GeoJSON.Feature = { + type: 'Feature', + properties: { status: 0 }, + geometry: { type: 'Point', coordinates: [2, 2] }, +} + +const makePointFeature = (id: number): GeoJSON.Feature => ({ + type: 'Feature', + properties: { id, status: 0 }, + geometry: { type: 'Point', coordinates: [id, id] }, +}) + +describe('useStyledClusteredData', () => { + beforeEach(() => { + useTaskMapContextMock.mockReset() + }) + + it('returns the original collection unchanged when nothing is selected or active', () => { + setContext(new Set(), null) + const data: GeoJSON.FeatureCollection = { + type: 'FeatureCollection', + features: [makePointFeature(1)], + } + + const { result } = renderHook(() => useStyledClusteredData(data)) + + expect(result.current).toBe(data) + }) + + it('leaves cluster features untouched', () => { + setContext(new Set([1]), null) + const data: GeoJSON.FeatureCollection = { + type: 'FeatureCollection', + features: [clusterFeature], + } + + const { result } = renderHook(() => useStyledClusteredData(data)) + + expect(result.current.features[0]).toBe(clusterFeature) + }) + + it('leaves features without an id untouched', () => { + setContext(new Set([1]), null) + const data: GeoJSON.FeatureCollection = { + type: 'FeatureCollection', + features: [featureWithoutId], + } + + const { result } = renderHook(() => useStyledClusteredData(data)) + + expect(result.current.features[0]).toBe(featureWithoutId) + }) + + it('marks a lasso-selected feature with isLassoSelected', () => { + setContext(new Set([1]), null) + const data: GeoJSON.FeatureCollection = { + type: 'FeatureCollection', + features: [makePointFeature(1), makePointFeature(2)], + } + + const { result } = renderHook(() => useStyledClusteredData(data)) + + expect(result.current.features[0].properties?.isLassoSelected).toBe(true) + expect(result.current.features[1]).toBe(data.features[1]) + expect(result.current.features[1].properties?.isLassoSelected).toBeUndefined() + }) + + it('marks the active task feature with isActive', () => { + setContext(new Set(), 2) + const data: GeoJSON.FeatureCollection = { + type: 'FeatureCollection', + features: [makePointFeature(1), makePointFeature(2)], + } + + const { result } = renderHook(() => useStyledClusteredData(data)) + + expect(result.current.features[0]).toBe(data.features[0]) + expect(result.current.features[1].properties?.isActive).toBe(true) + }) + + it('marks a feature that is both selected and active with both flags', () => { + setContext(new Set([1]), 1) + const data: GeoJSON.FeatureCollection = { + type: 'FeatureCollection', + features: [makePointFeature(1)], + } + + const { result } = renderHook(() => useStyledClusteredData(data)) + + expect(result.current.features[0].properties?.isLassoSelected).toBe(true) + expect(result.current.features[0].properties?.isActive).toBe(true) + }) + + it('preserves existing properties on a styled feature', () => { + setContext(new Set([1]), null) + const data: GeoJSON.FeatureCollection = { + type: 'FeatureCollection', + features: [makePointFeature(1)], + } + + const { result } = renderHook(() => useStyledClusteredData(data)) + + expect(result.current.features[0].properties?.status).toBe(0) + expect(result.current.features[0].properties?.id).toBe(1) + }) + + it('memoizes the result when inputs do not change', () => { + setContext(new Set([1]), null) + const data: GeoJSON.FeatureCollection = { + type: 'FeatureCollection', + features: [makePointFeature(1)], + } + + const { result, rerender } = renderHook(() => useStyledClusteredData(data)) + const first = result.current + + rerender() + + expect(result.current).toBe(first) + }) +}) diff --git a/src/components/Pages/TaskEditPage/TaskMap/useTaskMapShortcuts.test.tsx b/src/components/Pages/TaskEditPage/TaskMap/useTaskMapShortcuts.test.tsx new file mode 100644 index 000000000..8005b2403 --- /dev/null +++ b/src/components/Pages/TaskEditPage/TaskMap/useTaskMapShortcuts.test.tsx @@ -0,0 +1,209 @@ +import { renderHook } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { KeyboardShortcut } from '@/components/Pages/TaskEditPage/contexts/KeyboardShortcutsContext' +import type { TaskBundle } from '@/components/Pages/TaskEditPage/contexts/TaskBundleContext' +import type { LassoMode } from '@/components/Pages/TaskEditPage/contexts/TaskMapContext' + +const { useRegisterShortcutsMock, useTaskBundleContextMock, useTaskMapContextMock } = vi.hoisted( + () => ({ + useRegisterShortcutsMock: vi.fn(), + useTaskBundleContextMock: vi.fn(), + useTaskMapContextMock: vi.fn(), + }) +) + +vi.mock('@/components/Pages/TaskEditPage/contexts/KeyboardShortcutsContext', () => ({ + useRegisterShortcuts: useRegisterShortcutsMock, +})) +vi.mock('@/components/Pages/TaskEditPage/contexts/TaskBundleContext', () => ({ + useTaskBundleContext: useTaskBundleContextMock, +})) +vi.mock('@/components/Pages/TaskEditPage/contexts/TaskMapContext', () => ({ + useTaskMapContext: useTaskMapContextMock, +})) + +import { useTaskMapShortcuts } from './useTaskMapShortcuts' + +interface Overrides { + activeBundle?: TaskBundle | null + showBundleOnly?: boolean + setShowBundleOnly?: (v: boolean) => void + setShowDeleteDialog?: (v: boolean) => void + markersHidden?: boolean + setMarkersHidden?: (v: boolean) => void + drawingMode?: LassoMode + startDrawing?: (mode: 'select') => void + cancelDrawing?: () => void +} + +const setContext = ({ + activeBundle = null, + showBundleOnly = false, + setShowBundleOnly = vi.fn(), + setShowDeleteDialog = vi.fn(), + markersHidden = false, + setMarkersHidden = vi.fn(), + drawingMode = null, + startDrawing = vi.fn(), + cancelDrawing = vi.fn(), +}: Overrides) => { + useTaskBundleContextMock.mockReturnValue({ + activeBundle, + showBundleOnly, + setShowBundleOnly, + setShowDeleteDialog, + }) + useTaskMapContextMock.mockReturnValue({ + markersHidden, + setMarkersHidden, + drawingMode, + startDrawing, + cancelDrawing, + }) +} + +const shortcutsPassedToRegister = (): KeyboardShortcut[] => + useRegisterShortcutsMock.mock.calls[useRegisterShortcutsMock.mock.calls.length - 1][1] + +const findShortcut = (key: string): KeyboardShortcut => { + const shortcut = shortcutsPassedToRegister().find((s) => s.key === key) + if (!shortcut) throw new Error(`shortcut ${key} not registered`) + return shortcut +} + +describe('useTaskMapShortcuts', () => { + beforeEach(() => { + useRegisterShortcutsMock.mockReset() + useTaskBundleContextMock.mockReset() + useTaskMapContextMock.mockReset() + }) + + it('registers shortcuts under the task-map id with all five shortcuts', () => { + setContext({}) + + renderHook(() => useTaskMapShortcuts()) + + expect(useRegisterShortcutsMock).toHaveBeenCalledTimes(1) + const [id, shortcuts] = useRegisterShortcutsMock.mock.calls[0] + expect(id).toBe('task-map') + expect(shortcuts.map((s: KeyboardShortcut) => s.key)).toEqual(['D', 'F', 'H', 'Delete', 'Esc']) + }) + + it('D shortcut starts a select drawing session when not already drawing, and is enabled', () => { + const startDrawing = vi.fn() + setContext({ drawingMode: null, startDrawing }) + + renderHook(() => useTaskMapShortcuts()) + const shortcut = findShortcut('D') + + expect(shortcut.enabled).toBe(true) + shortcut.handler?.() + expect(startDrawing).toHaveBeenCalledWith('select') + }) + + it('D shortcut is disabled and does not start drawing again while already drawing', () => { + const startDrawing = vi.fn() + setContext({ drawingMode: 'select', startDrawing }) + + renderHook(() => useTaskMapShortcuts()) + const shortcut = findShortcut('D') + + expect(shortcut.enabled).toBe(false) + shortcut.handler?.() + expect(startDrawing).not.toHaveBeenCalled() + }) + + it('F shortcut toggles showBundleOnly and is enabled only with an active bundle', () => { + const setShowBundleOnly = vi.fn() + setContext({ + showBundleOnly: false, + setShowBundleOnly, + activeBundle: { bundleId: 1, taskIds: [1, 2], name: 'b' }, + }) + + renderHook(() => useTaskMapShortcuts()) + const shortcut = findShortcut('F') + + expect(shortcut.enabled).toBe(true) + shortcut.handler?.() + expect(setShowBundleOnly).toHaveBeenCalledWith(true) + }) + + it('F shortcut is disabled when there is no active bundle', () => { + setContext({ activeBundle: null }) + + renderHook(() => useTaskMapShortcuts()) + const shortcut = findShortcut('F') + + expect(shortcut.enabled).toBe(false) + }) + + it('H shortcut toggles markersHidden and is always enabled', () => { + const setMarkersHidden = vi.fn() + setContext({ markersHidden: false, setMarkersHidden, activeBundle: null }) + + renderHook(() => useTaskMapShortcuts()) + const shortcut = findShortcut('H') + + expect(shortcut.enabled).toBe(true) + shortcut.handler?.() + expect(setMarkersHidden).toHaveBeenCalledWith(true) + }) + + it('Delete shortcut opens the delete dialog and is enabled only with an active bundle', () => { + const setShowDeleteDialog = vi.fn() + setContext({ + setShowDeleteDialog, + activeBundle: { bundleId: 1, taskIds: [1, 2], name: 'b' }, + }) + + renderHook(() => useTaskMapShortcuts()) + const shortcut = findShortcut('Delete') + + expect(shortcut.enabled).toBe(true) + shortcut.handler?.() + expect(setShowDeleteDialog).toHaveBeenCalledWith(true) + }) + + it('Delete shortcut is disabled without an active bundle', () => { + setContext({ activeBundle: null }) + + renderHook(() => useTaskMapShortcuts()) + const shortcut = findShortcut('Delete') + + expect(shortcut.enabled).toBe(false) + }) + + it('Esc shortcut cancels drawing and is enabled only while drawing', () => { + const cancelDrawing = vi.fn() + setContext({ drawingMode: 'select', cancelDrawing }) + + renderHook(() => useTaskMapShortcuts()) + const shortcut = findShortcut('Esc') + + expect(shortcut.enabled).toBe(true) + shortcut.handler?.() + expect(cancelDrawing).toHaveBeenCalledTimes(1) + }) + + it('Esc shortcut is disabled when not drawing', () => { + setContext({ drawingMode: null }) + + renderHook(() => useTaskMapShortcuts()) + const shortcut = findShortcut('Esc') + + expect(shortcut.enabled).toBe(false) + }) + + it('memoizes the shortcuts array reference when nothing relevant changes', () => { + setContext({}) + + const { rerender } = renderHook(() => useTaskMapShortcuts()) + const first = shortcutsPassedToRegister() + + rerender() + + const second = shortcutsPassedToRegister() + expect(second).toBe(first) + }) +}) diff --git a/src/components/Pages/TeamsPage/teamSchema.test.ts b/src/components/Pages/TeamsPage/teamSchema.test.ts new file mode 100644 index 000000000..a17a2fb27 --- /dev/null +++ b/src/components/Pages/TeamsPage/teamSchema.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from 'vitest' +import { teamFormSchema } from './teamSchema.ts' + +const validInput = { + name: 'Road Warriors', + description: 'A team dedicated to fixing road connectivity issues.', + avatarURL: 'https://example.com/avatar.png', +} + +describe('teamFormSchema', () => { + it('accepts a fully populated valid input', () => { + const result = teamFormSchema.safeParse(validInput) + expect(result.success).toBe(true) + }) + + it('accepts the minimal input with only the required name field', () => { + const result = teamFormSchema.safeParse({ name: 'Minimal Team' }) + expect(result.success).toBe(true) + }) + + it('rejects an empty name', () => { + const result = teamFormSchema.safeParse({ ...validInput, name: '' }) + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.issues.some((issue) => issue.message === 'Name is required')).toBe(true) + } + }) + + it('rejects a name over 100 characters', () => { + const result = teamFormSchema.safeParse({ ...validInput, name: 'a'.repeat(101) }) + expect(result.success).toBe(false) + if (!result.success) { + expect( + result.error.issues.some((issue) => issue.message === 'Keep it under 100 characters') + ).toBe(true) + } + }) + + it('accepts a name exactly at the 100 character boundary', () => { + const result = teamFormSchema.safeParse({ ...validInput, name: 'a'.repeat(100) }) + expect(result.success).toBe(true) + }) + + it('rejects a description over 1000 characters', () => { + const result = teamFormSchema.safeParse({ ...validInput, description: 'a'.repeat(1001) }) + expect(result.success).toBe(false) + if (!result.success) { + expect( + result.error.issues.some((issue) => issue.message === 'Keep it under 1000 characters') + ).toBe(true) + } + }) + + it('accepts a description exactly at the 1000 character boundary', () => { + const result = teamFormSchema.safeParse({ ...validInput, description: 'a'.repeat(1000) }) + expect(result.success).toBe(true) + }) + + it('omits the optional description field without failing', () => { + const { description: _description, ...rest } = validInput + const result = teamFormSchema.safeParse(rest) + expect(result.success).toBe(true) + }) + + it('rejects a malformed avatarURL', () => { + const result = teamFormSchema.safeParse({ ...validInput, avatarURL: 'not-a-url' }) + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.issues.some((issue) => issue.message === 'Must be a valid URL')).toBe( + true + ) + } + }) + + it('accepts an empty string for avatarURL', () => { + const result = teamFormSchema.safeParse({ ...validInput, avatarURL: '' }) + expect(result.success).toBe(true) + }) + + it('omits the optional avatarURL field without failing', () => { + const { avatarURL: _avatarURL, ...rest } = validInput + const result = teamFormSchema.safeParse(rest) + expect(result.success).toBe(true) + }) + + it('rejects input missing the required name field entirely', () => { + const { name: _name, ...rest } = validInput + const result = teamFormSchema.safeParse(rest) + expect(result.success).toBe(false) + }) +}) diff --git a/src/components/shared/ConfigureColumnsDialog/useColumnConfig.test.tsx b/src/components/shared/ConfigureColumnsDialog/useColumnConfig.test.tsx new file mode 100644 index 000000000..bdecf9f36 --- /dev/null +++ b/src/components/shared/ConfigureColumnsDialog/useColumnConfig.test.tsx @@ -0,0 +1,187 @@ +import { act, renderHook } from '@testing-library/react' +import { beforeEach, describe, expect, it } from 'vitest' +import { useColumnConfig } from './useColumnConfig' + +type Key = 'name' | 'status' | 'priority' | 'created' + +const defaults = { + available: ['priority', 'created'] as Key[], + added: ['name', 'status'] as Key[], +} + +describe('useColumnConfig', () => { + beforeEach(() => { + window.localStorage.clear() + }) + + it('initializes with the provided defaults when nothing is in storage', () => { + const { result } = renderHook(() => useColumnConfig('tasks-table', defaults)) + + expect(result.current.config).toEqual(defaults) + }) + + it('persists the initial config to localStorage under a namespaced key', () => { + renderHook(() => useColumnConfig('tasks-table', defaults)) + + const stored = window.localStorage.getItem('mr4:columns:tasks-table') + expect(stored).not.toBeNull() + expect(JSON.parse(stored as string)).toEqual(defaults) + }) + + it('loads a sanitized config from localStorage on mount', () => { + window.localStorage.setItem( + 'mr4:columns:tasks-table', + JSON.stringify({ available: ['created'], added: ['status', 'name'] }) + ) + + const { result } = renderHook(() => useColumnConfig('tasks-table', defaults)) + + expect(result.current.config).toEqual({ available: ['created'], added: ['status', 'name'] }) + }) + + it('filters out keys from storage that are not among the known defaults', () => { + window.localStorage.setItem( + 'mr4:columns:tasks-table', + JSON.stringify({ available: ['created', 'bogus'], added: ['name', 'ghost'] }) + ) + + const { result } = renderHook(() => useColumnConfig('tasks-table', defaults)) + + expect(result.current.config).toEqual({ available: ['created'], added: ['name'] }) + }) + + it('falls back to defaults when localStorage contains malformed JSON', () => { + window.localStorage.setItem('mr4:columns:tasks-table', '{not valid json') + + const { result } = renderHook(() => useColumnConfig('tasks-table', defaults)) + + expect(result.current.config).toEqual(defaults) + }) + + it('falls back to defaults when localStorage entry is missing expected shape', () => { + window.localStorage.setItem('mr4:columns:tasks-table', JSON.stringify({})) + + const { result } = renderHook(() => useColumnConfig('tasks-table', defaults)) + + expect(result.current.config).toEqual(defaults) + }) + + it('uses a distinct storage key per tableId', () => { + renderHook(() => useColumnConfig('other-table', defaults)) + + expect(window.localStorage.getItem('mr4:columns:other-table')).not.toBeNull() + expect(window.localStorage.getItem('mr4:columns:tasks-table')).toBeNull() + }) + + it('addColumn moves a key from available to added', () => { + const { result } = renderHook(() => useColumnConfig('tasks-table', defaults)) + + act(() => result.current.addColumn('priority')) + + expect(result.current.config).toEqual({ + available: ['created'], + added: ['name', 'status', 'priority'], + }) + }) + + it('addColumn is a no-op when the key is already added', () => { + const { result } = renderHook(() => useColumnConfig('tasks-table', defaults)) + + act(() => result.current.addColumn('name')) + + expect(result.current.config).toEqual(defaults) + }) + + it('removeColumn moves a key from added back to available', () => { + const { result } = renderHook(() => useColumnConfig('tasks-table', defaults)) + + act(() => result.current.removeColumn('status')) + + expect(result.current.config).toEqual({ + available: ['priority', 'created', 'status'], + added: ['name'], + }) + }) + + it('removeColumn is a no-op when the key is not currently added', () => { + const { result } = renderHook(() => useColumnConfig('tasks-table', defaults)) + + act(() => result.current.removeColumn('priority')) + + expect(result.current.config).toEqual(defaults) + }) + + it('moveColumn reorders an added key toward the front', () => { + const { result } = renderHook(() => + useColumnConfig('tasks-table', { + available: [] as Key[], + added: ['name', 'status', 'priority'], + }) + ) + + act(() => result.current.moveColumn('priority', -1)) + + expect(result.current.config.added).toEqual(['name', 'priority', 'status']) + }) + + it('moveColumn reorders an added key toward the back', () => { + const { result } = renderHook(() => + useColumnConfig('tasks-table', { + available: [] as Key[], + added: ['name', 'status', 'priority'], + }) + ) + + act(() => result.current.moveColumn('name', 1)) + + expect(result.current.config.added).toEqual(['status', 'name', 'priority']) + }) + + it('moveColumn does nothing when moving past the start of the list', () => { + const { result } = renderHook(() => + useColumnConfig('tasks-table', { available: [] as Key[], added: ['name', 'status'] }) + ) + + act(() => result.current.moveColumn('name', -1)) + + expect(result.current.config.added).toEqual(['name', 'status']) + }) + + it('moveColumn does nothing when moving past the end of the list', () => { + const { result } = renderHook(() => + useColumnConfig('tasks-table', { available: [] as Key[], added: ['name', 'status'] }) + ) + + act(() => result.current.moveColumn('status', 1)) + + expect(result.current.config.added).toEqual(['name', 'status']) + }) + + it('moveColumn does nothing for a key that is not in added', () => { + const { result } = renderHook(() => useColumnConfig('tasks-table', defaults)) + + act(() => result.current.moveColumn('priority', 1)) + + expect(result.current.config).toEqual(defaults) + }) + + it('reset restores the config back to the provided defaults after changes', () => { + const { result } = renderHook(() => useColumnConfig('tasks-table', defaults)) + + act(() => result.current.addColumn('priority')) + expect(result.current.config).not.toEqual(defaults) + + act(() => result.current.reset()) + + expect(result.current.config).toEqual(defaults) + }) + + it('persists updated config to localStorage after a mutation', () => { + const { result } = renderHook(() => useColumnConfig('tasks-table', defaults)) + + act(() => result.current.addColumn('priority')) + + const stored = JSON.parse(window.localStorage.getItem('mr4:columns:tasks-table') as string) + expect(stored).toEqual({ available: ['created'], added: ['name', 'status', 'priority'] }) + }) +}) diff --git a/src/components/shared/StatusBreakdownBar/useActionSummary.test.tsx b/src/components/shared/StatusBreakdownBar/useActionSummary.test.tsx new file mode 100644 index 000000000..2a3b72b34 --- /dev/null +++ b/src/components/shared/StatusBreakdownBar/useActionSummary.test.tsx @@ -0,0 +1,110 @@ +import { renderHook } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import type { ActionCounts } from './useActionSummary' +import { STATUS_ORDER, useActionSummary } from './useActionSummary' + +describe('useActionSummary', () => { + it('returns an empty summary when actions is undefined', () => { + const { result } = renderHook(() => useActionSummary(undefined)) + + expect(result.current).toEqual({ total: 0, segments: [], counts: {} }) + }) + + it('maps `available` to the canonical `created` status key', () => { + const { result } = renderHook(() => useActionSummary({ available: 10, total: 10 })) + + expect(result.current.counts).toEqual({ created: 10 }) + expect(result.current.segments).toEqual([ + { key: 'created', label: 'Available', color: expect.any(String), count: 10, percent: 100 }, + ]) + }) + + it('omits zero-count and unspecified statuses from segments and counts', () => { + const { result } = renderHook(() => + useActionSummary({ total: 5, fixed: 5, skipped: 0, deleted: undefined }) + ) + + expect(result.current.counts).toEqual({ fixed: 5 }) + expect(result.current.segments).toHaveLength(1) + expect(result.current.segments[0].key).toBe('fixed') + }) + + it('treats negative counts as zero', () => { + const { result } = renderHook(() => useActionSummary({ total: 5, fixed: -3 })) + + expect(result.current.counts).toEqual({}) + expect(result.current.segments).toEqual([]) + }) + + it('uses actions.total when provided instead of the summed counts', () => { + const { result } = renderHook(() => useActionSummary({ total: 100, fixed: 5, skipped: 5 })) + + expect(result.current.total).toBe(100) + expect(result.current.segments.find((s) => s.key === 'fixed')?.percent).toBe(5) + }) + + it('falls back to summed counts when actions.total is absent', () => { + const { result } = renderHook(() => useActionSummary({ fixed: 5, skipped: 15 })) + + expect(result.current.total).toBe(20) + expect(result.current.segments.find((s) => s.key === 'fixed')?.percent).toBe(25) + expect(result.current.segments.find((s) => s.key === 'skipped')?.percent).toBe(75) + }) + + it('falls back to summed counts when actions.total is explicitly zero', () => { + const { result } = renderHook(() => useActionSummary({ total: 0, fixed: 4 })) + + expect(result.current.total).toBe(4) + expect(result.current.counts).toEqual({ fixed: 4 }) + }) + + it('produces zero percent segments when everything (including total) is zero', () => { + const { result } = renderHook(() => useActionSummary({ total: 0 })) + + expect(result.current.total).toBe(0) + expect(result.current.segments).toEqual([]) + }) + + it('orders segments according to STATUS_ORDER regardless of input field order', () => { + const { result } = renderHook(() => + useActionSummary({ + disabled: 1, + available: 1, + deleted: 1, + tooHard: 1, + skipped: 1, + falsePositive: 1, + alreadyFixed: 1, + fixed: 1, + }) + ) + + expect(result.current.segments.map((s) => s.key)).toEqual(STATUS_ORDER) + }) + + it('recomputes the summary when the actions input changes', () => { + const { result, rerender } = renderHook( + (actions: ActionCounts | undefined) => useActionSummary(actions), + { initialProps: { total: 10, fixed: 10 } as ActionCounts | undefined } + ) + + expect(result.current.counts).toEqual({ fixed: 10 }) + + rerender({ total: 10, skipped: 10 }) + + expect(result.current.counts).toEqual({ skipped: 10 }) + }) + + it('recomputes back to the empty summary when actions becomes undefined', () => { + const { result, rerender } = renderHook( + (actions: ActionCounts | undefined) => useActionSummary(actions), + { initialProps: { total: 10, fixed: 10 } as ActionCounts | undefined } + ) + + expect(result.current.total).toBe(10) + + rerender(undefined) + + expect(result.current).toEqual({ total: 0, segments: [], counts: {} }) + }) +}) diff --git a/src/hooks/useChallengeProgress.test.tsx b/src/hooks/useChallengeProgress.test.tsx new file mode 100644 index 000000000..6ede4dfb0 --- /dev/null +++ b/src/hooks/useChallengeProgress.test.tsx @@ -0,0 +1,188 @@ +import { renderHook } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import { STATUS_HEX, STATUS_KEY_TO_ID } from '@/lib/taskConstants' +import type { CompletionMetrics } from '@/types/Challenge' + +const { getChallengeStatsMock } = vi.hoisted(() => ({ getChallengeStatsMock: vi.fn() })) + +vi.mock('@/api', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + api: { + ...actual.api, + challenge: { ...actual.api.challenge, getChallengeStats: getChallengeStatsMock }, + }, + } +}) + +import { useChallengeProgress } from './useChallengeProgress' + +const colorFor = (key: string) => STATUS_HEX[STATUS_KEY_TO_ID[key] ?? -1] ?? '#9ca3af' + +describe('useChallengeProgress', () => { + it('returns empty/zero state when there is no stats data and no fallback', () => { + getChallengeStatsMock.mockReturnValue({ data: undefined }) + + const { result } = renderHook(() => useChallengeProgress(1)) + + expect(result.current).toEqual({ + completionPercentage: 0, + segments: [], + hasActions: false, + total: 0, + tasksRemaining: 0, + }) + }) + + it('falls back to the provided completion metrics when per-challenge stats are not loaded', () => { + getChallengeStatsMock.mockReturnValue({ data: undefined }) + const fallback: CompletionMetrics = { + total: 10, + available: 3, + fixed: 5, + falsePositive: 1, + skipped: 1, + deleted: 0, + alreadyFixed: 0, + tooHard: 0, + answered: 0, + validated: 0, + disabled: 0, + tasksRemaining: 4, + } + + const { result } = renderHook(() => useChallengeProgress(1, fallback)) + + expect(result.current.hasActions).toBe(true) + expect(result.current.total).toBe(10) + expect(result.current.completionPercentage).toBe(60) + expect(result.current.tasksRemaining).toBe(4) + }) + + it('prefers per-challenge stats data over the fallback when both are available', () => { + getChallengeStatsMock.mockReturnValue({ + data: [{ actions: { total: 4, fixed: 4 } }], + }) + const fallback: CompletionMetrics = { + total: 999, + available: 0, + fixed: 0, + falsePositive: 0, + skipped: 0, + deleted: 0, + alreadyFixed: 0, + tooHard: 0, + answered: 0, + validated: 0, + disabled: 0, + tasksRemaining: 0, + } + + const { result } = renderHook(() => useChallengeProgress(1, fallback)) + + expect(result.current.total).toBe(4) + expect(result.current.completionPercentage).toBe(100) + }) + + it('derives the total by summing per-status fields when actions.total is missing', () => { + getChallengeStatsMock.mockReturnValue({ + data: [{ actions: { fixed: 2, available: 3 } }], + }) + + const { result } = renderHook(() => useChallengeProgress(1)) + + expect(result.current.total).toBe(5) + expect(result.current.completionPercentage).toBe(40) + }) + + it('derives the total by summing when actions.total is zero', () => { + getChallengeStatsMock.mockReturnValue({ + data: [{ actions: { total: 0, fixed: 1, skipped: 1 } }], + }) + + const { result } = renderHook(() => useChallengeProgress(1)) + + expect(result.current.total).toBe(2) + }) + + it('builds completed segments with correct percentage, color, and title', () => { + getChallengeStatsMock.mockReturnValue({ + data: [{ actions: { total: 10, fixed: 4, falsePositive: 2, alreadyFixed: 1 } }], + }) + + const { result } = renderHook(() => useChallengeProgress(1)) + + const fixedSegment = result.current.segments.find((s) => s.key === 'fixed') + expect(fixedSegment).toEqual({ + key: 'fixed', + percentage: 40, + color: colorFor('fixed'), + title: 'Fixed: 4', + }) + + const falsePositiveSegment = result.current.segments.find((s) => s.key === 'falsePositive') + expect(falsePositiveSegment?.percentage).toBe(20) + + const alreadyFixedSegment = result.current.segments.find((s) => s.key === 'alreadyFixed') + expect(alreadyFixedSegment?.percentage).toBe(10) + }) + + it('gives tooHard its own red-tinted problem segment', () => { + getChallengeStatsMock.mockReturnValue({ + data: [{ actions: { total: 10, fixed: 5, tooHard: 3 } }], + }) + + const { result } = renderHook(() => useChallengeProgress(1)) + + const problemSegment = result.current.segments.find((s) => s.key === 'tooHard') + expect(problemSegment).toMatchObject({ + key: 'tooHard', + percentage: 30, + color: '#ef4444', + opacity: 0.55, + title: "Can't Complete: 3", + }) + }) + + it('collapses all other non-completed statuses into a single gray remaining fill segment', () => { + getChallengeStatsMock.mockReturnValue({ + data: [{ actions: { total: 10, fixed: 5, available: 3, skipped: 2 } }], + }) + + const { result } = renderHook(() => useChallengeProgress(1)) + + const remainingSegments = result.current.segments.filter((s) => s.key === 'remaining') + expect(remainingSegments).toHaveLength(1) + expect(remainingSegments[0]).toMatchObject({ + color: '#9ca3af', + opacity: 0.45, + title: 'Remaining: 5', + }) + + const totalPercentage = result.current.segments.reduce((acc, s) => acc + s.percentage, 0) + expect(totalPercentage).toBeCloseTo(100) + }) + + it('produces no segments and reports hasActions false when total is 0', () => { + getChallengeStatsMock.mockReturnValue({ + data: [{ actions: { total: 0, fixed: 0 } }], + }) + + const { result } = renderHook(() => useChallengeProgress(1)) + + expect(result.current.segments).toEqual([]) + expect(result.current.hasActions).toBe(false) + expect(result.current.completionPercentage).toBe(0) + }) + + it('computes tasksRemaining as available + skipped + tooHard', () => { + getChallengeStatsMock.mockReturnValue({ + data: [{ actions: { total: 10, available: 2, skipped: 1, tooHard: 1, fixed: 6 } }], + }) + + const { result } = renderHook(() => useChallengeProgress(1)) + + expect(result.current.tasksRemaining).toBe(4) + }) +}) diff --git a/src/hooks/useCopyToClipboard.test.tsx b/src/hooks/useCopyToClipboard.test.tsx new file mode 100644 index 000000000..a8df2aafd --- /dev/null +++ b/src/hooks/useCopyToClipboard.test.tsx @@ -0,0 +1,90 @@ +import { act, renderHook } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { useCopyToClipboard } from './useCopyToClipboard' + +vi.mock('@/lib/logger', () => ({ + logger: { warn: vi.fn(), debug: vi.fn(), info: vi.fn(), error: vi.fn() }, +})) + +import { logger } from '@/lib/logger' + +describe('useCopyToClipboard', () => { + afterEach(() => { + vi.useRealTimers() + vi.unstubAllGlobals() + vi.clearAllMocks() + }) + + it('starts with isCopied false', () => { + const { result } = renderHook(() => useCopyToClipboard()) + expect(result.current.isCopied).toBe(false) + }) + + it('copies text via navigator.clipboard.writeText and flips isCopied to true', async () => { + const writeText = vi.fn().mockResolvedValue(undefined) + vi.stubGlobal('navigator', { ...navigator, clipboard: { writeText } }) + + const { result } = renderHook(() => useCopyToClipboard()) + + await act(async () => { + await result.current.copy('hello world') + }) + + expect(writeText).toHaveBeenCalledWith('hello world') + expect(result.current.isCopied).toBe(true) + }) + + it('resets isCopied to false 2000ms after a successful copy', async () => { + vi.useFakeTimers() + const writeText = vi.fn().mockResolvedValue(undefined) + vi.stubGlobal('navigator', { ...navigator, clipboard: { writeText } }) + + const { result } = renderHook(() => useCopyToClipboard()) + + await act(async () => { + await result.current.copy('hello world') + }) + expect(result.current.isCopied).toBe(true) + + act(() => { + vi.advanceTimersByTime(2000) + }) + + expect(result.current.isCopied).toBe(false) + vi.useRealTimers() + }) + + it('warns and does nothing when clipboard is unsupported', async () => { + vi.stubGlobal('navigator', { ...navigator, clipboard: undefined }) + + const { result } = renderHook(() => useCopyToClipboard()) + + await act(async () => { + await result.current.copy('hello world') + }) + + expect(logger.warn).toHaveBeenCalledWith('Clipboard not supported') + expect(result.current.isCopied).toBe(false) + }) + + it('warns and keeps isCopied false when writeText rejects', async () => { + const writeText = vi.fn().mockRejectedValue(new Error('denied')) + vi.stubGlobal('navigator', { ...navigator, clipboard: { writeText } }) + + const { result } = renderHook(() => useCopyToClipboard()) + + await act(async () => { + await result.current.copy('hello world') + }) + + expect(logger.warn).toHaveBeenCalledWith('Copy failed', expect.anything()) + expect(result.current.isCopied).toBe(false) + }) + + it('returns a stable copy function reference across renders', () => { + const { result, rerender } = renderHook(() => useCopyToClipboard()) + const firstCopy = result.current.copy + rerender() + expect(result.current.copy).toBe(firstCopy) + }) +}) diff --git a/src/hooks/useNotificationFilters.test.tsx b/src/hooks/useNotificationFilters.test.tsx new file mode 100644 index 000000000..c0cf8854e --- /dev/null +++ b/src/hooks/useNotificationFilters.test.tsx @@ -0,0 +1,295 @@ +import { act, renderHook } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { Notification } from '@/types/Notification' +import { NotificationType } from '@/types/Notification' + +type SearchState = Record + +const { searchRef, navigateMock } = vi.hoisted(() => ({ + searchRef: { current: {} as SearchState }, + navigateMock: vi.fn(), +})) + +vi.mock('@tanstack/react-router', () => ({ + useNavigate: () => navigateMock, + useSearch: () => searchRef.current, +})) + +import { DEFAULT_FILTER_STATE, useNotificationFilters } from './useNotificationFilters' + +function makeNotification(props: Partial): Notification { + return { + id: 1, + notificationType: NotificationType.SYSTEM, + isRead: false, + ...props, + } as Notification +} + +function applyNavigate(opts: { search: SearchState | ((prev: SearchState) => SearchState) }) { + searchRef.current = + typeof opts.search === 'function' ? opts.search(searchRef.current) : opts.search +} + +describe('useNotificationFilters', () => { + beforeEach(() => { + localStorage.clear() + searchRef.current = {} + navigateMock.mockReset() + navigateMock.mockImplementation(applyNavigate) + }) + + afterEach(() => { + localStorage.clear() + }) + + it('returns the default filter state when there are no URL search params', () => { + const { result } = renderHook(() => useNotificationFilters([])) + + expect(result.current.category).toBe('all') + expect(result.current.status).toBe('all') + expect(result.current.filterTask).toBe('all') + expect(result.current.filterType).toBe('all') + expect(result.current.filterFrom).toBe('all') + expect(result.current.filterChallenge).toBe('all') + expect(result.current.hasActiveFilters).toBe(false) + }) + + it('reads initial state from the URL search params', () => { + searchRef.current = { category: 'mention', status: 'unread' } + + const { result } = renderHook(() => useNotificationFilters([])) + + expect(result.current.category).toBe('mention') + expect(result.current.status).toBe('unread') + expect(result.current.hasActiveFilters).toBe(true) + }) + + it('falls back to "all" for an invalid category/status value in the URL', () => { + searchRef.current = { category: 'bogus', status: 'bogus' } + + const { result } = renderHook(() => useNotificationFilters([])) + + expect(result.current.category).toBe('all') + expect(result.current.status).toBe('all') + }) + + it('setCategory writes the category to the URL and clears it back out when set to default', () => { + const { result, rerender } = renderHook(() => useNotificationFilters([])) + + act(() => result.current.setCategory('review')) + rerender() + expect(result.current.category).toBe('review') + expect(searchRef.current.category).toBe('review') + + act(() => result.current.setCategory('all')) + rerender() + expect(result.current.category).toBe('all') + expect(searchRef.current.category).toBeUndefined() + }) + + it('setFilterTask/setFilterType/setFilterFrom/setFilterChallenge write to their own search keys', () => { + const { result, rerender } = renderHook(() => useNotificationFilters([])) + + act(() => { + result.current.setFilterTask('42') + result.current.setFilterType('3') + result.current.setFilterFrom('alice') + result.current.setFilterChallenge('Fix roads') + }) + rerender() + + expect(result.current.filterTask).toBe('42') + expect(result.current.filterType).toBe('3') + expect(result.current.filterFrom).toBe('alice') + expect(result.current.filterChallenge).toBe('Fix roads') + }) + + it('clearFilters removes every filter key from the URL', () => { + searchRef.current = { + category: 'mention', + status: 'unread', + task: '1', + type: '2', + from: 'bob', + challenge: 'x', + } + const { result, rerender } = renderHook(() => useNotificationFilters([])) + + act(() => result.current.clearFilters()) + rerender() + + expect(result.current.category).toBe('all') + expect(result.current.status).toBe('all') + expect(result.current.filterTask).toBe('all') + expect(result.current.filterType).toBe('all') + expect(result.current.filterFrom).toBe('all') + expect(result.current.filterChallenge).toBe('all') + expect(result.current.hasActiveFilters).toBe(false) + }) + + it('applyFilterState writes a whole filter state at once, dropping default fields', () => { + const { result, rerender } = renderHook(() => useNotificationFilters([])) + + act(() => + result.current.applyFilterState({ + ...DEFAULT_FILTER_STATE, + category: 'team', + filterFrom: 'carol', + }) + ) + rerender() + + expect(result.current.category).toBe('team') + expect(result.current.filterFrom).toBe('carol') + expect(searchRef.current.status).toBeUndefined() + }) + + it('filterOptions extracts sorted unique tasks/types/fromUsers/challenges', () => { + const notifications = [ + makeNotification({ + id: 1, + taskId: 5, + notificationType: 2, + fromUsername: 'bob', + challengeName: 'B', + }), + makeNotification({ + id: 2, + taskId: 1, + notificationType: 1, + fromUsername: 'alice', + challengeName: 'A', + }), + makeNotification({ + id: 3, + taskId: 5, + notificationType: 1, + fromUsername: 'alice', + challengeName: 'A', + }), + ] + + const { result } = renderHook(() => useNotificationFilters(notifications)) + + expect(result.current.filterOptions.tasks).toEqual([1, 5]) + expect(result.current.filterOptions.types).toEqual([1, 2]) + expect(result.current.filterOptions.fromUsers).toEqual(['alice', 'bob']) + expect(result.current.filterOptions.challenges).toEqual(['A', 'B']) + }) + + it('categoryCounts and statusCounts tally notifications correctly', () => { + const notifications = [ + makeNotification({ id: 1, notificationType: NotificationType.MENTION, isRead: false }), + makeNotification({ id: 2, notificationType: NotificationType.MENTION, isRead: true }), + makeNotification({ id: 3, notificationType: NotificationType.TEAM, isRead: false }), + ] + + const { result } = renderHook(() => useNotificationFilters(notifications)) + + expect(result.current.categoryCounts.all).toBe(3) + expect(result.current.categoryCounts.mention).toBe(2) + expect(result.current.categoryCounts.team).toBe(1) + expect(result.current.statusCounts).toEqual({ all: 3, unread: 2, read: 1 }) + }) + + it('applyFilters filters by category, status, task, type, from, and challenge', () => { + const notifications = [ + makeNotification({ + id: 1, + taskId: 5, + notificationType: NotificationType.MENTION, + isRead: false, + fromUsername: 'alice', + challengeName: 'A', + }), + makeNotification({ + id: 2, + taskId: 6, + notificationType: NotificationType.TEAM, + isRead: true, + fromUsername: 'bob', + challengeName: 'B', + }), + ] + searchRef.current = { category: 'mention' } + const { result } = renderHook(() => useNotificationFilters(notifications)) + + expect(result.current.applyFilters(notifications)).toEqual([notifications[0]]) + }) + + it('applyFilters returns unread-only notifications when status=unread', () => { + const notifications = [ + makeNotification({ id: 1, isRead: false }), + makeNotification({ id: 2, isRead: true }), + ] + searchRef.current = { status: 'unread' } + const { result } = renderHook(() => useNotificationFilters(notifications)) + + expect(result.current.applyFilters(notifications)).toEqual([notifications[0]]) + }) + + it('persists the current filter state to localStorage as it changes', () => { + const { result, rerender } = renderHook(() => useNotificationFilters([])) + + act(() => result.current.setCategory('challenge')) + rerender() + + const stored = JSON.parse(localStorage.getItem('mr4:notifications:lastFilters') ?? '{}') + expect(stored.category).toBe('challenge') + }) + + // NOTE: the persistence effect (writes `currentState` to localStorage) and the + // restore effect (reads localStorage back) both run in the same mount pass, in + // declaration order. On a true fresh mount the persistence effect runs first and + // writes the freshly-computed *default* state, which the restore effect then reads + // back as-is — so restoration never actually kicks in on first mount. This test + // documents that observed behavior rather than the aspirational one described in + // the hook's comments. + it('does not restore a previously-persisted non-default state on a fresh mount', () => { + localStorage.setItem( + 'mr4:notifications:lastFilters', + JSON.stringify({ ...DEFAULT_FILTER_STATE, category: 'review', filterFrom: 'dave' }) + ) + + const { result, rerender } = renderHook(() => useNotificationFilters([])) + rerender() + + expect(result.current.category).toBe('all') + expect(result.current.filterFrom).toBe('all') + const stored = JSON.parse(localStorage.getItem('mr4:notifications:lastFilters') ?? '{}') + expect(stored.category).toBe('all') + }) + + it('does not restore persisted filters when the URL already has filter params', () => { + localStorage.setItem( + 'mr4:notifications:lastFilters', + JSON.stringify({ ...DEFAULT_FILTER_STATE, category: 'review' }) + ) + searchRef.current = { status: 'unread' } + + const { result, rerender } = renderHook(() => useNotificationFilters([])) + rerender() + + expect(result.current.category).toBe('all') + expect(result.current.status).toBe('unread') + }) + + it('does not restore when persisted filters are all defaults', () => { + localStorage.setItem('mr4:notifications:lastFilters', JSON.stringify(DEFAULT_FILTER_STATE)) + const navigateCallsBefore = navigateMock.mock.calls.length + + renderHook(() => useNotificationFilters([])) + + // Only the persistence-effect write should have happened, no restore navigate call + // that carries non-default values. + expect(searchRef.current.category).toBeUndefined() + expect(navigateMock.mock.calls.length).toBeGreaterThanOrEqual(navigateCallsBefore) + }) + + it('ignores malformed JSON in localStorage without throwing', () => { + localStorage.setItem('mr4:notifications:lastFilters', '{not-json') + + expect(() => renderHook(() => useNotificationFilters([]))).not.toThrow() + }) +}) diff --git a/src/hooks/useNotificationThreads.test.tsx b/src/hooks/useNotificationThreads.test.tsx new file mode 100644 index 000000000..6bbcc8492 --- /dev/null +++ b/src/hooks/useNotificationThreads.test.tsx @@ -0,0 +1,101 @@ +import { renderHook } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import type { Notification } from '@/types/Notification' +import { NotificationType } from '@/types/Notification' +import { useNotificationThreads } from './useNotificationThreads' + +function makeNotification(props: Partial): Notification { + return { + id: 1, + notificationType: NotificationType.SYSTEM, + isRead: false, + ...props, + } as Notification +} + +describe('useNotificationThreads', () => { + it('returns an empty map for no notifications', () => { + const { result } = renderHook(() => useNotificationThreads([])) + expect(result.current).toEqual({}) + }) + + it('groups notifications sharing the same taskId into one thread', () => { + const a = makeNotification({ id: 1, taskId: 42 }) + const b = makeNotification({ id: 2, taskId: 42 }) + const c = makeNotification({ id: 3, taskId: 7 }) + + const { result } = renderHook(() => useNotificationThreads([a, b, c])) + + expect(result.current['task:42']).toEqual([a, b]) + expect(result.current['task:7']).toEqual([c]) + }) + + it('groups challenge-comment notifications by challengeId', () => { + const a = makeNotification({ + id: 1, + notificationType: NotificationType.CHALLENGE_COMMENT, + challengeId: 5, + }) + const b = makeNotification({ + id: 2, + notificationType: NotificationType.CHALLENGE_COMMENT, + challengeId: 5, + }) + + const { result } = renderHook(() => useNotificationThreads([a, b])) + + expect(result.current['challenge:5']).toEqual([a, b]) + }) + + it('groups team notifications by targetId', () => { + const a = makeNotification({ id: 1, notificationType: NotificationType.TEAM, targetId: 9 }) + + const { result } = renderHook(() => useNotificationThreads([a])) + + expect(result.current['team:9']).toEqual([a]) + }) + + it('falls back to grouping by challengeName when no ids are present', () => { + const a = makeNotification({ id: 1, challengeName: 'Fix roads' }) + const b = makeNotification({ id: 2, challengeName: 'Fix roads' }) + + const { result } = renderHook(() => useNotificationThreads([a, b])) + + expect(result.current['challenge-name:Fix roads']).toEqual([a, b]) + }) + + it('falls back to a single-notification thread keyed by id', () => { + const a = makeNotification({ id: 55 }) + + const { result } = renderHook(() => useNotificationThreads([a])) + + expect(result.current['single:55']).toEqual([a]) + }) + + it('memoizes the result while notifications is unchanged', () => { + const notifications = [makeNotification({ id: 1, taskId: 1 })] + const { result, rerender } = renderHook( + ({ notifications }) => useNotificationThreads(notifications), + { initialProps: { notifications } } + ) + const first = result.current + + rerender({ notifications }) + + expect(result.current).toBe(first) + }) + + it('recomputes when the notifications array reference changes', () => { + const notifications = [makeNotification({ id: 1, taskId: 1 })] + const { result, rerender } = renderHook( + ({ notifications }) => useNotificationThreads(notifications), + { initialProps: { notifications } } + ) + const first = result.current + + rerender({ notifications: [...notifications] }) + + expect(result.current).not.toBe(first) + expect(result.current).toEqual(first) + }) +}) diff --git a/src/hooks/useRowSelection.test.tsx b/src/hooks/useRowSelection.test.tsx new file mode 100644 index 000000000..b32eb11ac --- /dev/null +++ b/src/hooks/useRowSelection.test.tsx @@ -0,0 +1,76 @@ +import { act, renderHook } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import { useRowSelection } from './useRowSelection' + +describe('useRowSelection', () => { + it('starts with no rows selected', () => { + const { result } = renderHook(() => useRowSelection()) + + expect(result.current.ids.size).toBe(0) + expect(result.current.idList).toEqual([]) + expect(result.current.count).toBe(0) + expect(result.current.has(1)).toBe(false) + }) + + it('toggle adds an unselected id and removes a selected one', () => { + const { result } = renderHook(() => useRowSelection()) + + act(() => result.current.toggle(1)) + expect(result.current.has(1)).toBe(true) + expect(result.current.count).toBe(1) + expect(result.current.idList).toEqual([1]) + + act(() => result.current.toggle(1)) + expect(result.current.has(1)).toBe(false) + expect(result.current.count).toBe(0) + }) + + it('set(id, true) selects and set(id, false) deselects', () => { + const { result } = renderHook(() => useRowSelection()) + + act(() => result.current.set(2, true)) + expect(result.current.has(2)).toBe(true) + + act(() => result.current.set(2, false)) + expect(result.current.has(2)).toBe(false) + }) + + it('set is a no-op when the requested state already matches', () => { + const { result } = renderHook(() => useRowSelection()) + + act(() => result.current.set(3, true)) + const idsAfterFirstSet = result.current.ids + + act(() => result.current.set(3, true)) + expect(result.current.ids).toBe(idsAfterFirstSet) + }) + + it('selectAll replaces the selection with the given ids', () => { + const { result } = renderHook(() => useRowSelection()) + + act(() => result.current.toggle(99)) + act(() => result.current.selectAll([1, 2, 3])) + + expect(result.current.idList.sort()).toEqual([1, 2, 3]) + expect(result.current.has(99)).toBe(false) + expect(result.current.count).toBe(3) + }) + + it('clear empties the selection', () => { + const { result } = renderHook(() => useRowSelection()) + + act(() => result.current.selectAll([1, 2, 3])) + act(() => result.current.clear()) + + expect(result.current.count).toBe(0) + expect(result.current.idList).toEqual([]) + }) + + it('works with string ids', () => { + const { result } = renderHook(() => useRowSelection()) + + act(() => result.current.toggle('abc')) + expect(result.current.has('abc')).toBe(true) + expect(result.current.idList).toEqual(['abc']) + }) +}) diff --git a/src/hooks/useShareSupport.test.tsx b/src/hooks/useShareSupport.test.tsx new file mode 100644 index 000000000..815d86f8a --- /dev/null +++ b/src/hooks/useShareSupport.test.tsx @@ -0,0 +1,37 @@ +import { renderHook } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { useShareSupport } from './useShareSupport' + +describe('useShareSupport', () => { + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('returns false when navigator.share is not a function', () => { + vi.stubGlobal('navigator', { ...navigator, share: undefined }) + + const { result } = renderHook(() => useShareSupport()) + + expect(result.current).toBe(false) + }) + + it('returns true when navigator.share is a function', () => { + vi.stubGlobal('navigator', { ...navigator, share: vi.fn() }) + + const { result } = renderHook(() => useShareSupport()) + + expect(result.current).toBe(true) + }) + + it('reflects a share function added between renders', () => { + vi.stubGlobal('navigator', { ...navigator, share: undefined }) + + const { result, rerender } = renderHook(() => useShareSupport()) + expect(result.current).toBe(false) + + vi.stubGlobal('navigator', { ...navigator, share: vi.fn() }) + rerender() + + expect(result.current).toBe(true) + }) +}) diff --git a/src/hooks/useWebSocketEvents.test.tsx b/src/hooks/useWebSocketEvents.test.tsx new file mode 100644 index 000000000..75f30d008 --- /dev/null +++ b/src/hooks/useWebSocketEvents.test.tsx @@ -0,0 +1,376 @@ +import { act } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import { renderHookWithClient } from '@/test/queryClient' +import type { ChallengeTaskMarkersResponse } from '@/types/Challenge' +import type { TaskGetResponse } from '@/types/Task' +import type { User } from '@/types/User' +import type { WebSocketMessageTypes } from '@/types/WebSocket' + +const { wsState, authState, congratulateState, wsLoggerMock } = vi.hoisted(() => ({ + wsState: { lastMessage: null as WebSocketMessageTypes | null, subscribe: vi.fn() }, + authState: { user: undefined as User | undefined }, + congratulateState: { enqueue: vi.fn() }, + wsLoggerMock: { warn: vi.fn(), error: vi.fn(), info: vi.fn(), debug: vi.fn() }, +})) + +vi.mock('@/contexts/WebSocketContext', () => ({ + useWebSocketContext: () => wsState, +})) +vi.mock('@/contexts/AuthContext', () => ({ + useAuthContext: () => authState, +})) +vi.mock('@/contexts/CongratulateContext', () => ({ + useCongratulate: () => congratulateState, +})) +vi.mock('@/lib/logger', () => ({ + wsLogger: wsLoggerMock, + logger: { warn: vi.fn(), error: vi.fn(), info: vi.fn(), debug: vi.fn() }, +})) + +import { useWebSocketEvents } from './useWebSocketEvents' + +function makeUser(id: number): User { + return { id } as unknown as User +} + +function setLastMessage(message: WebSocketMessageTypes | null) { + wsState.lastMessage = message +} + +describe('useWebSocketEvents', () => { + it('subscribes to the "tasks" stream once on mount', () => { + wsState.subscribe = vi.fn() + wsState.lastMessage = null + authState.user = undefined + + renderHookWithClient(() => useWebSocketEvents()) + + expect(wsState.subscribe).toHaveBeenCalledTimes(1) + expect(wsState.subscribe).toHaveBeenCalledWith('tasks') + }) + + it('re-subscribes when the context provides a new subscribe function (e.g. after a reconnect)', () => { + wsState.subscribe = vi.fn() + wsState.lastMessage = null + authState.user = undefined + + const { rerender } = renderHookWithClient(() => useWebSocketEvents()) + expect(wsState.subscribe).toHaveBeenCalledTimes(1) + + const newSubscribe = vi.fn() + wsState.subscribe = newSubscribe + rerender() + + expect(newSubscribe).toHaveBeenCalledWith('tasks') + }) + + it('does not resubscribe when rerendered with the same subscribe reference', () => { + const subscribe = vi.fn() + wsState.subscribe = subscribe + wsState.lastMessage = null + authState.user = undefined + + const { rerender } = renderHookWithClient(() => useWebSocketEvents()) + expect(subscribe).toHaveBeenCalledTimes(1) + + rerender() + + expect(subscribe).toHaveBeenCalledTimes(1) + }) + + it('unmounts cleanly without throwing', () => { + wsState.subscribe = vi.fn() + wsState.lastMessage = null + authState.user = undefined + + const { unmount } = renderHookWithClient(() => useWebSocketEvents()) + + expect(() => unmount()).not.toThrow() + }) + + it('enqueues an achievement congratulation and invalidates user caches for the current user', () => { + wsState.subscribe = vi.fn() + wsState.lastMessage = null + authState.user = makeUser(7) + congratulateState.enqueue = vi.fn() + + const { rerender, queryClient } = renderHookWithClient(() => useWebSocketEvents()) + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries') + + act(() => { + setLastMessage({ + messageType: 'achievement-awarded', + data: { userId: 7, achievement: [10, 20] }, + }) + rerender() + }) + + expect(congratulateState.enqueue).toHaveBeenCalledWith({ + kind: 'achievement', + achievementId: 10, + }) + expect(congratulateState.enqueue).toHaveBeenCalledWith({ + kind: 'achievement', + achievementId: 20, + }) + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['user', 'whoami'] }) + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['user', 7] }) + }) + + it('ignores an achievement message awarded to a different user', () => { + wsState.subscribe = vi.fn() + wsState.lastMessage = null + authState.user = makeUser(7) + congratulateState.enqueue = vi.fn() + + const { rerender, queryClient } = renderHookWithClient(() => useWebSocketEvents()) + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries') + + act(() => { + setLastMessage({ + messageType: 'achievement-awarded', + data: { userId: 999, achievement: [10] }, + }) + rerender() + }) + + expect(congratulateState.enqueue).not.toHaveBeenCalled() + expect(invalidateSpy).not.toHaveBeenCalled() + }) + + it('patches the cached task status and invalidates history/aggregates on a status-changing task event', () => { + wsState.subscribe = vi.fn() + wsState.lastMessage = null + authState.user = makeUser(7) + + const { rerender, queryClient } = renderHookWithClient(() => useWebSocketEvents()) + + queryClient.setQueryData(['task', 5], { + id: 5, + status: 0, + } as unknown as TaskGetResponse) + queryClient.setQueryData(['challenge', 'taskMarkers', 3], { + markers: [{ id: 5, status: 0 } as unknown as ChallengeTaskMarkersResponse['markers'][number]], + overlaps: [], + }) + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries') + + act(() => { + setLastMessage({ + messageType: 'task-completed', + data: { + task: { id: 5, parent: 3, status: 1 }, + challenge: { id: 3, parentId: 1, name: 'c', enabled: true }, + project: null, + byUser: { userId: 7, osmId: 7, displayName: 'me', avatarURL: '' }, + }, + }) + rerender() + }) + + expect(queryClient.getQueryData(['task', 5])?.status).toBe(1) + expect( + queryClient.getQueryData(['challenge', 'taskMarkers', 3]) + ?.markers[0].status + ).toBe(1) + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['task', 'history', 5] }) + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['challenge', 3] }) + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['challenge', 'stats', 3] }) + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['challenge', 'activity', 3] }) + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['user', 'whoami'] }) + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['user', 7] }) + }) + + it('does not invalidate challenge aggregates when the task status is unchanged', () => { + wsState.subscribe = vi.fn() + wsState.lastMessage = null + authState.user = makeUser(7) + + const { rerender, queryClient } = renderHookWithClient(() => useWebSocketEvents()) + queryClient.setQueryData(['task', 5], { + id: 5, + status: 1, + } as unknown as TaskGetResponse) + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries') + + act(() => { + setLastMessage({ + messageType: 'task-update', + data: { + task: { id: 5, parent: 3, status: 1 }, + challenge: null, + project: null, + byUser: null, + }, + }) + rerender() + }) + + expect(invalidateSpy).not.toHaveBeenCalledWith({ queryKey: ['challenge', 3] }) + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['task', 'history', 5] }) + }) + + it('sets lockedBy on the marker when a task is claimed, and clears it when released', () => { + wsState.subscribe = vi.fn() + wsState.lastMessage = null + authState.user = undefined + + const { rerender, queryClient } = renderHookWithClient(() => useWebSocketEvents()) + queryClient.setQueryData(['challenge', 'taskMarkers', 3], { + markers: [ + { id: 5, lockedBy: null } as unknown as ChallengeTaskMarkersResponse['markers'][number], + ], + overlaps: [], + }) + + act(() => { + setLastMessage({ + messageType: 'task-claimed', + data: { + task: { id: 5, parent: 3, status: null }, + challenge: null, + project: null, + byUser: { userId: 42, osmId: 42, displayName: 'other', avatarURL: '' }, + }, + }) + rerender() + }) + + expect( + queryClient.getQueryData(['challenge', 'taskMarkers', 3]) + ?.markers[0].lockedBy + ).toBe(42) + + act(() => { + setLastMessage({ + messageType: 'task-released', + data: { + task: { id: 5, parent: 3, status: null }, + challenge: null, + project: null, + byUser: null, + }, + }) + rerender() + }) + + expect( + queryClient.getQueryData(['challenge', 'taskMarkers', 3]) + ?.markers[0].lockedBy + ).toBe(null) + }) + + it('invalidates task and challenge aggregate caches on a review event', () => { + wsState.subscribe = vi.fn() + wsState.lastMessage = null + authState.user = undefined + + const { rerender, queryClient } = renderHookWithClient(() => useWebSocketEvents()) + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries') + + act(() => { + setLastMessage({ + messageType: 'review-new', + data: { + taskWithReview: { + task: { id: 8, parent: 4, status: 2 }, + review: { reviewStatus: 0 }, + }, + }, + }) + rerender() + }) + + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['task', 8] }) + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['task', 'history', 8] }) + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['challenge', 4] }) + }) + + it('invalidates the team and current user teamMemberships caches on a team update', () => { + wsState.subscribe = vi.fn() + wsState.lastMessage = null + authState.user = makeUser(7) + + const { rerender, queryClient } = renderHookWithClient(() => useWebSocketEvents()) + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries') + + act(() => { + setLastMessage({ messageType: 'team-update', data: { teamId: 12, userId: 7 } }) + rerender() + }) + + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['team', 12] }) + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['user', 7, 'teamMemberships'] }) + }) + + it('invalidates the notifications cache on a new-notification message', () => { + wsState.subscribe = vi.fn() + wsState.lastMessage = null + authState.user = undefined + + const { rerender, queryClient } = renderHookWithClient(() => useWebSocketEvents()) + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries') + + act(() => { + setLastMessage({ messageType: 'notification-new', data: { userId: 1, notificationType: 0 } }) + rerender() + }) + + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['user', 'notifications'] }) + }) + + it('does not reprocess the same message object when an unrelated dependency changes', () => { + wsState.subscribe = vi.fn() + wsState.lastMessage = null + authState.user = makeUser(1) + congratulateState.enqueue = vi.fn() + + const message: WebSocketMessageTypes = { + messageType: 'notification-new', + data: { userId: 1, notificationType: 0 }, + } + setLastMessage(message) + + const { rerender, queryClient } = renderHookWithClient(() => useWebSocketEvents()) + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries') + // First render already processed `message` via mount effect; reset the spy + // so we only observe invalidations from the rerender below. + invalidateSpy.mockClear() + + act(() => { + // Change an unrelated effect dependency (`user`) while `lastMessage` + // keeps the exact same object reference — the WeakSet guard should + // prevent re-processing the already-seen message. + authState.user = makeUser(2) + rerender() + }) + + expect(invalidateSpy).not.toHaveBeenCalledWith({ queryKey: ['user', 'notifications'] }) + }) + + it('logs and swallows errors thrown while dispatching a malformed message', () => { + wsState.subscribe = vi.fn() + wsState.lastMessage = null + authState.user = makeUser(1) + wsLoggerMock.warn = vi.fn() + + const malformedTaskMessage = { + messageType: 'task-completed', + data: {}, + } as unknown as WebSocketMessageTypes + + const { rerender } = renderHookWithClient(() => useWebSocketEvents()) + + expect(() => { + act(() => { + setLastMessage(malformedTaskMessage) + rerender() + }) + }).not.toThrow() + + expect(wsLoggerMock.warn).toHaveBeenCalledWith( + 'WebSocket dispatch error', + expect.objectContaining({ error: expect.anything() }) + ) + }) +}) diff --git a/src/routes/_app/challenge/[$challengeId]/index.test.ts b/src/routes/_app/challenge/[$challengeId]/index.test.ts new file mode 100644 index 000000000..5306da4b8 --- /dev/null +++ b/src/routes/_app/challenge/[$challengeId]/index.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest' +import type { z } from 'zod' +import { Route } from './index.tsx' + +// The route's search schema (`comments`) is a local, unexported const, so it's +// reached the same way the router itself reaches it: off `Route.options`. +const schema = Route.options.validateSearch as unknown as z.ZodType<{ comments?: number }> + +describe('challenge route search schema', () => { + it('accepts an empty search (comments is optional)', () => { + const result = schema.safeParse({}) + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.comments).toBeUndefined() + } + }) + + it('coerces a numeric string comments param to a number', () => { + const result = schema.safeParse({ comments: '1' }) + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.comments).toBe(1) + } + }) + + it('accepts a numeric comments param', () => { + const result = schema.safeParse({ comments: 1 }) + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.comments).toBe(1) + } + }) + + it('rejects a non-numeric comments param', () => { + const result = schema.safeParse({ comments: 'not-a-number' }) + expect(result.success).toBe(false) + }) +}) diff --git a/src/routes/_app/index.test.ts b/src/routes/_app/index.test.ts new file mode 100644 index 000000000..4d7953d90 --- /dev/null +++ b/src/routes/_app/index.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from 'vitest' +import type { z } from 'zod' +import { Route } from './index.tsx' + +type ChallengesSearch = { + viewMode?: 'grid' | 'list' | 'grid-map' + difficulty?: 'Any' | 'Easy' | 'Normal' | 'Expert' + workOn?: + | 'Anything' + | 'Roads / Pedestrian / Cycleways' + | 'Water' + | 'Points / Areas of Interest' + | 'Buildings' + | 'Land Use / Administrative Boundaries' + | 'Transit' + categories?: string + sortBy?: 'name' | 'created' | 'modified' | 'popularity' | 'difficulty' + global?: boolean + osm_type?: 'N' | 'W' | 'R' + osm_id?: number +} + +// The route's search schema is a local, unexported const, so it's reached the +// same way the router itself reaches it: off `Route.options`. +const schema = Route.options.validateSearch as unknown as z.ZodType + +describe('explore challenges route search schema', () => { + it('accepts an empty search', () => { + const result = schema.safeParse({}) + expect(result.success).toBe(true) + }) + + it('accepts a fully populated valid search', () => { + const result = schema.safeParse({ + viewMode: 'list', + difficulty: 'Easy', + workOn: 'Water', + categories: 'water,buildings', + sortBy: 'popularity', + global: true, + osm_type: 'W', + osm_id: 42, + }) + expect(result.success).toBe(true) + }) + + it('falls back to the catch default for an invalid viewMode', () => { + const result = schema.safeParse({ viewMode: 'not-a-mode' }) + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.viewMode).toBe('grid-map') + } + }) + + it('falls back to the catch default for an invalid difficulty', () => { + const result = schema.safeParse({ difficulty: 'Impossible' }) + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.difficulty).toBe('Any') + } + }) + + it('falls back to the catch default for an invalid workOn', () => { + const result = schema.safeParse({ workOn: 'Something else' }) + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.workOn).toBe('Anything') + } + }) + + it('falls back to the catch default for an invalid sortBy', () => { + const result = schema.safeParse({ sortBy: 'nonsense' }) + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.sortBy).toBe('name') + } + }) + + it('falls back to the catch default for a non-boolean global', () => { + const result = schema.safeParse({ global: 'yes' }) + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.global).toBe(false) + } + }) + + it('rejects an invalid osm_type since it has no catch fallback', () => { + const result = schema.safeParse({ osm_type: 'X' }) + expect(result.success).toBe(false) + }) + + it('rejects a non-numeric osm_id since it has no catch fallback', () => { + const result = schema.safeParse({ osm_id: 'not-a-number' }) + expect(result.success).toBe(false) + }) +}) diff --git a/src/routes/_app/manage/challenge/new.test.ts b/src/routes/_app/manage/challenge/new.test.ts new file mode 100644 index 000000000..16cbf7a28 --- /dev/null +++ b/src/routes/_app/manage/challenge/new.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest' +import type { z } from 'zod' +import { Route } from './new.tsx' + +// The route's search schema (`projectId`) is a local, unexported const, so +// it's reached the same way the router itself reaches it: off `Route.options`. +const schema = Route.options.validateSearch as unknown as z.ZodType<{ projectId?: number }> + +describe('manage challenge new route search schema', () => { + it('accepts an empty search (projectId is optional)', () => { + const result = schema.safeParse({}) + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.projectId).toBeUndefined() + } + }) + + it('accepts a numeric projectId', () => { + const result = schema.safeParse({ projectId: 7 }) + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.projectId).toBe(7) + } + }) + + it('rejects a non-numeric projectId (no coercion configured)', () => { + const result = schema.safeParse({ projectId: '7' }) + expect(result.success).toBe(false) + }) +}) diff --git a/src/routes/_app/notifications.test.ts b/src/routes/_app/notifications.test.ts new file mode 100644 index 000000000..8714c669b --- /dev/null +++ b/src/routes/_app/notifications.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest' +import type { z } from 'zod' +import { Route } from './notifications.tsx' + +type NotificationsSearch = { + notificationId?: number + category?: 'all' | 'task_comment' | 'mention' | 'review' | 'challenge' | 'team' | 'system' + status?: 'all' | 'unread' | 'read' + task?: string + type?: string + from?: string + challenge?: string + view?: string +} + +// The route's search schema is a local, unexported const, so it's reached the +// same way the router itself reaches it: off `Route.options`. +const schema = Route.options.validateSearch as unknown as z.ZodType + +describe('notifications route search schema', () => { + it('accepts an empty search', () => { + const result = schema.safeParse({}) + expect(result.success).toBe(true) + }) + + it('accepts a fully populated valid search', () => { + const result = schema.safeParse({ + notificationId: 5, + category: 'mention', + status: 'unread', + task: '123', + type: '2', + from: 'someone', + challenge: '456', + view: 'list', + }) + expect(result.success).toBe(true) + }) + + it('coerces a numeric string notificationId to a number', () => { + const result = schema.safeParse({ notificationId: '5' }) + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.notificationId).toBe(5) + } + }) + + it('rejects a non-numeric notificationId', () => { + const result = schema.safeParse({ notificationId: 'not-a-number' }) + expect(result.success).toBe(false) + }) + + it('rejects an invalid category value (no catch fallback)', () => { + const result = schema.safeParse({ category: 'not-a-category' }) + expect(result.success).toBe(false) + }) + + it('rejects an invalid status value (no catch fallback)', () => { + const result = schema.safeParse({ status: 'archived' }) + expect(result.success).toBe(false) + }) + + it('accepts arbitrary strings for the free-form fields', () => { + const result = schema.safeParse({ task: 'anything', type: 'anything', from: 'anyone' }) + expect(result.success).toBe(true) + }) +}) diff --git a/src/routes/_app/tasks/[$taskId]/index.test.ts b/src/routes/_app/tasks/[$taskId]/index.test.ts new file mode 100644 index 000000000..5469e679b --- /dev/null +++ b/src/routes/_app/tasks/[$taskId]/index.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest' +import type { z } from 'zod' +import { Route } from './index.tsx' + +// The route's search schema (`tab`) is a local, unexported const, so it's +// reached the same way the router itself reaches it: off `Route.options`. +const schema = Route.options.validateSearch as unknown as z.ZodType<{ + tab?: 'task' | 'properties' | 'comments' | 'osm' +}> + +describe('task route search schema', () => { + it('accepts an empty search (tab is optional)', () => { + const result = schema.safeParse({}) + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.tab).toBeUndefined() + } + }) + + it.each(['task', 'properties', 'comments', 'osm'] as const)('accepts the %s tab value', (tab) => { + const result = schema.safeParse({ tab }) + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.tab).toBe(tab) + } + }) + + it('rejects an unrecognized tab value', () => { + const result = schema.safeParse({ tab: 'history' }) + expect(result.success).toBe(false) + }) +}) diff --git a/src/test/queryClient.tsx b/src/test/queryClient.tsx new file mode 100644 index 000000000..dd20f829a --- /dev/null +++ b/src/test/queryClient.tsx @@ -0,0 +1,26 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { renderHook } from '@testing-library/react' +import type { ReactNode } from 'react' + +export function createTestQueryClient() { + return new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }) +} + +export function renderHookWithClient( + hook: (props: TProps) => TResult, + options: { client?: QueryClient; initialProps?: TProps } = {} +) { + const client = options.client ?? createTestQueryClient() + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ) + return { + ...renderHook(hook, { wrapper, initialProps: options.initialProps }), + queryClient: client, + } +} diff --git a/vite.config.ts b/vite.config.ts index e84eccc1c..584733f80 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -42,7 +42,11 @@ export default defineConfig({ plugins: [ svgr(), tailwindcss(), - tanstackRouter({ target: 'react', autoCodeSplitting: true }), + tanstackRouter({ + target: 'react', + autoCodeSplitting: true, + routeFileIgnorePattern: '\\.test\\.', + }), viteReact(), runtimeEnv(), ], From 64faed4cd12fe6974534891c22f1d858016105b3 Mon Sep 17 00:00:00 2001 From: Collin Beczak Date: Thu, 16 Jul 2026 17:45:14 -0500 Subject: [PATCH 2/6] Refactor bounding box validation logic and update related tests - Update `validateBBoxArea` function to reject bounding boxes with missing or extra components. - Modify unit tests to reflect new validation behavior, ensuring proper error messages are returned for invalid formats. - Enhance test coverage for bounding box validation scenarios. --- src/api/osm.test.ts | 18 ++++++++++++------ src/api/osm.ts | 4 +++- src/hooks/useNotificationFilters.test.tsx | 17 +++++------------ src/hooks/useNotificationFilters.ts | 20 +++++++++++--------- 4 files changed, 31 insertions(+), 28 deletions(-) diff --git a/src/api/osm.test.ts b/src/api/osm.test.ts index ede4a7721..38a4789fe 100644 --- a/src/api/osm.test.ts +++ b/src/api/osm.test.ts @@ -56,12 +56,18 @@ describe('osm', () => { }) }) - it('treats a missing component as a non-finite area rather than an invalid format', () => { - // A missing 4th component destructures to `undefined`, and `Number.isNaN(undefined)` - // is false, so this doesn't hit the "Invalid bounding box format" branch. The - // resulting area is NaN, which also fails the `area > MAX_OSM_AREA` check, so the - // box is (surprisingly) reported as valid. - expect(osm.validateBBoxArea('1,2,3')).toEqual({ isValid: true }) + it('rejects a bounding box missing its 4th component', () => { + expect(osm.validateBBoxArea('1,2,3')).toEqual({ + isValid: false, + error: 'Invalid bounding box format', + }) + }) + + it('rejects a bounding box with extra components', () => { + expect(osm.validateBBoxArea('1,2,3,4,5')).toEqual({ + isValid: false, + error: 'Invalid bounding box format', + }) }) it('rejects a bounding box whose area exceeds the maximum allowed', () => { diff --git a/src/api/osm.ts b/src/api/osm.ts index e8b83b5de..de5405ef2 100644 --- a/src/api/osm.ts +++ b/src/api/osm.ts @@ -135,9 +135,11 @@ export const osm = { * @returns Object with isValid boolean and error message if invalid */ validateBBoxArea: (bbox: string): { isValid: boolean; error?: string } => { - const [minLon, minLat, maxLon, maxLat] = bbox.split(',').map(Number) + const parts = bbox.split(',') + const [minLon, minLat, maxLon, maxLat] = parts.map(Number) if ( + parts.length !== 4 || Number.isNaN(minLon) || Number.isNaN(minLat) || Number.isNaN(maxLon) || diff --git a/src/hooks/useNotificationFilters.test.tsx b/src/hooks/useNotificationFilters.test.tsx index c0cf8854e..126b06ab2 100644 --- a/src/hooks/useNotificationFilters.test.tsx +++ b/src/hooks/useNotificationFilters.test.tsx @@ -239,14 +239,7 @@ describe('useNotificationFilters', () => { expect(stored.category).toBe('challenge') }) - // NOTE: the persistence effect (writes `currentState` to localStorage) and the - // restore effect (reads localStorage back) both run in the same mount pass, in - // declaration order. On a true fresh mount the persistence effect runs first and - // writes the freshly-computed *default* state, which the restore effect then reads - // back as-is — so restoration never actually kicks in on first mount. This test - // documents that observed behavior rather than the aspirational one described in - // the hook's comments. - it('does not restore a previously-persisted non-default state on a fresh mount', () => { + it('restores a previously-persisted non-default state on a fresh mount', () => { localStorage.setItem( 'mr4:notifications:lastFilters', JSON.stringify({ ...DEFAULT_FILTER_STATE, category: 'review', filterFrom: 'dave' }) @@ -255,10 +248,10 @@ describe('useNotificationFilters', () => { const { result, rerender } = renderHook(() => useNotificationFilters([])) rerender() - expect(result.current.category).toBe('all') - expect(result.current.filterFrom).toBe('all') - const stored = JSON.parse(localStorage.getItem('mr4:notifications:lastFilters') ?? '{}') - expect(stored.category).toBe('all') + expect(result.current.category).toBe('review') + expect(result.current.filterFrom).toBe('dave') + expect(searchRef.current.category).toBe('review') + expect(searchRef.current.from).toBe('dave') }) it('does not restore persisted filters when the URL already has filter params', () => { diff --git a/src/hooks/useNotificationFilters.ts b/src/hooks/useNotificationFilters.ts index 40c8cfd84..ac07972cc 100644 --- a/src/hooks/useNotificationFilters.ts +++ b/src/hooks/useNotificationFilters.ts @@ -252,17 +252,11 @@ export const useNotificationFilters = (notifications: Notification[]) => { [category, status, filterTask, filterType, filterFrom, filterChallenge] ) - useEffect(() => { - try { - localStorage.setItem(LAST_FILTERS_KEY, JSON.stringify(currentState)) - } catch { - // Reason: storage may be disabled (private mode, quota) — persistence is best-effort. - } - }, [currentState]) - // Reason: on first mount with no filter params in the URL, restore the persisted last state // so users come back to their previous filters. Uses a ref to guarantee run-once semantics - // without depending on `search` (which would re-trigger after the restore writes it). + // without depending on `search` (which would re-trigger after the restore writes it). This + // must run before the persist effect below — otherwise the persist effect overwrites + // localStorage with the just-computed default state before this ever reads the saved value. const hasRestoredRef = useRef(false) useEffect(() => { if (hasRestoredRef.current) return @@ -307,6 +301,14 @@ export const useNotificationFilters = (notifications: Notification[]) => { } }, [search, writeState]) + useEffect(() => { + try { + localStorage.setItem(LAST_FILTERS_KEY, JSON.stringify(currentState)) + } catch { + // Reason: storage may be disabled (private mode, quota) — persistence is best-effort. + } + }, [currentState]) + return { category, setCategory, From b1fb5705d46819e9e5e02417757797ac448f1728 Mon Sep 17 00:00:00 2001 From: Collin Beczak Date: Thu, 16 Jul 2026 18:24:28 -0500 Subject: [PATCH 3/6] add end to end tests --- docker-compose.test.yaml | 7 +- e2e/challenge-management.spec.ts | 68 ++++++++++++ e2e/challenge-search.spec.ts | 18 ++++ e2e/fixtures.ts | 101 ++++++++++++++++-- e2e/profile-settings.spec.ts | 54 ++++++++++ e2e/super-admin.spec.ts | 58 ++++++++++ e2e/task-workflow.spec.ts | 44 ++++++++ src/api/challenge/explore.test.tsx | 20 ++-- src/api/challenge/single.test.tsx | 30 +++--- src/api/index.test.ts | 2 +- src/api/osm.ts | 2 +- src/api/project.test.tsx | 4 +- src/api/service/index.test.tsx | 2 +- src/api/service/info.test.tsx | 4 +- src/api/task/comments.test.tsx | 4 +- src/api/task/multiple.test.tsx | 20 ++-- src/api/task/single.test.tsx | 4 +- src/api/task/tags.test.tsx | 2 +- src/api/taskBundle/queries.test.tsx | 2 +- src/api/team/index.test.tsx | 4 +- src/api/user/auth.test.tsx | 2 +- src/api/user/notifications.test.tsx | 2 +- src/api/user/profile.test.tsx | 4 +- .../ExploreChallengesPage/FilterBar/index.tsx | 2 +- .../ManageChallengeNew/ChallengeForm.test.tsx | 4 +- .../ManageProjectNew/ProjectForm.test.tsx | 2 +- .../ManageTaskEdit/TaskForm.test.tsx | 2 +- .../Editor/BoundsDrawControl.tsx | 14 +-- .../SettingsPage/UserSettingsForm/index.tsx | 20 ++-- .../TaskMap/TaskEditMapContext.tsx | 26 ++--- .../TaskMap/TaskGeometryLayer.tsx | 8 +- .../TaskMap/useAllMarkersMap.test.tsx | 2 +- .../TaskMap/useLassoBundleSync.test.tsx | 4 +- .../TaskMap/useLassoEvents.test.tsx | 26 ++--- .../TaskMap/useMapNavigation.test.tsx | 4 +- .../TaskMap/useMarkerVisibility.test.tsx | 4 +- src/hooks/useWebSocketEvents.test.tsx | 12 +-- src/lib/challengePermissions.test.ts | 4 +- src/lib/localGeoJSON.ts | 2 +- src/plugins/DynamicPluginLoader.ts | 2 +- .../challenge/[$challengeId]/index.test.ts | 2 +- src/routes/_app/index.test.ts | 2 +- src/routes/_app/manage/challenge/new.test.ts | 2 +- src/routes/_app/notifications.test.ts | 2 +- src/routes/_app/tasks/[$taskId]/index.test.ts | 2 +- src/test/setup.ts | 2 +- 46 files changed, 472 insertions(+), 136 deletions(-) create mode 100644 e2e/challenge-management.spec.ts create mode 100644 e2e/challenge-search.spec.ts create mode 100644 e2e/profile-settings.spec.ts create mode 100644 e2e/super-admin.spec.ts create mode 100644 e2e/task-workflow.spec.ts diff --git a/docker-compose.test.yaml b/docker-compose.test.yaml index ad3773243..540b8f61d 100644 --- a/docker-compose.test.yaml +++ b/docker-compose.test.yaml @@ -10,8 +10,13 @@ services: POSTGRES_DB: maproulette POSTGRES_USER: maproulette POSTGRES_PASSWORD: hunter2 + # No fixed host port: the backend reaches this over the compose network via + # the `db` hostname, and a fixed "5432:5432" mapping collides with any other + # local Postgres already bound to that port on the host. Publish without a + # host port so Docker picks a free one if you need to connect directly + # (`docker compose -f docker-compose.test.yaml port db 5432`). ports: - - "5432:5432" + - "5432" tmpfs: - /var/lib/postgresql/data healthcheck: diff --git a/e2e/challenge-management.spec.ts b/e2e/challenge-management.spec.ts new file mode 100644 index 000000000..a3f511b57 --- /dev/null +++ b/e2e/challenge-management.spec.ts @@ -0,0 +1,68 @@ +import { expect, test } from './fixtures' + +test('a user can edit an existing challenge and see the changes persisted', async ({ + page, + challenge, +}) => { + await page.goto(`/manage/challenge/${challenge.id}/edit`) + + const heading = page.getByRole('heading', { name: 'Challenge Editor' }) + await expect(heading).toBeVisible({ timeout: 15_000 }) + + const nameField = page.getByLabel('Name') + // The form is populated asynchronously once the challenge query resolves. + await expect(nameField).toHaveValue(challenge.name, { timeout: 15_000 }) + + const updatedName = `${challenge.name} (edited)` + const updatedDescription = 'Updated by the challenge-management E2E test.' + + await nameField.fill(updatedName) + await page.getByLabel('Description').fill(updatedDescription) + + await page.getByRole('button', { name: 'Update Challenge' }).click() + + await expect(page).toHaveURL(new RegExp(`/manage/challenge/${challenge.id}$`), { + timeout: 30_000, + }) + // The updated name renders in both a top toolbar heading and the page's + // main heading; either confirms the edit persisted and reloaded correctly. + await expect(page.getByRole('heading', { name: updatedName }).first()).toBeVisible({ + timeout: 15_000, + }) + + await page.getByRole('button', { name: 'Description' }).click() + await expect(page.getByRole('dialog')).toContainText(updatedDescription) +}) + +test('a user can archive an existing challenge and see it flagged as archived', async ({ + page, + challenge, +}) => { + await page.goto(`/manage/project/${challenge.projectId}`) + + await expect(page.getByText(challenge.name)).toBeVisible({ timeout: 20_000 }) + + // The table view keeps row actions in their own cell (rather than nested + // inside the challenge's link, as in grid/card view), so switch to it. + await page.getByRole('radio', { name: 'List view' }).click() + + const row = page.getByRole('row', { name: challenge.name }) + await expect(row).toBeVisible({ timeout: 15_000 }) + + await row.getByRole('button', { name: 'Open menu' }).click() + await page.getByRole('menuitem', { name: 'Archive challenge' }).click() + + // Once the archive mutation resolves and the challenge list is refetched, + // the same menu item is relabeled to offer the reverse action. + await row.getByRole('button', { name: 'Open menu' }).click() + await expect(page.getByRole('menuitem', { name: 'Unarchive challenge' })).toBeVisible({ + timeout: 15_000, + }) + await page.keyboard.press('Escape') + + // The "Archived" filter only shows challenges with isArchived set, so the + // challenge remaining visible after switching it on confirms the archived + // state took effect server-side, not just in the dropdown label. + await page.getByRole('switch', { name: 'Archived' }).click() + await expect(row).toBeVisible({ timeout: 15_000 }) +}) diff --git a/e2e/challenge-search.spec.ts b/e2e/challenge-search.spec.ts new file mode 100644 index 000000000..0dbc50a96 --- /dev/null +++ b/e2e/challenge-search.spec.ts @@ -0,0 +1,18 @@ +import { expect, test } from './fixtures' + +test('a user can find a challenge by name using the header search bar', async ({ + page, + challenge, +}) => { + await page.goto('/') + + const searchInput = page.getByRole('searchbox', { + name: /search for challenges, tasks or projects/i, + }) + await searchInput.click() + await searchInput.fill(challenge.name) + + await expect(page.getByRole('heading', { name: challenge.name })).toBeVisible({ + timeout: 15_000, + }) +}) diff --git a/e2e/fixtures.ts b/e2e/fixtures.ts index 888ba49eb..495a0b9d7 100644 --- a/e2e/fixtures.ts +++ b/e2e/fixtures.ts @@ -3,11 +3,26 @@ import { type APIRequestContext, test as base } from '@playwright/test' const BACKEND_URL = 'http://localhost:9000' const SUPER_KEY = 'super-secret-key' +const uniqueName = (prefix: string) => + `${prefix}-${Date.now()}-${Math.floor(Math.random() * 1_000_000)}` + export interface TestProject { id: number name: string } +export interface TestChallenge { + id: number + name: string + projectId: number +} + +export interface TestTask { + id: number + name: string + challengeId: number +} + async function createProject(request: APIRequestContext, name: string): Promise { const response = await request.post(`${BACKEND_URL}/api/v2/project`, { headers: { apiKey: SUPER_KEY, 'Content-Type': 'application/json' }, @@ -38,13 +53,83 @@ async function deleteProject(request: APIRequestContext, id: number): Promise({ - project: async ({ request }, use) => { - const name = `e2e-project-${Date.now()}-${Math.floor(Math.random() * 1_000_000)}` - const project = await createProject(request, name) - await use(project) - await deleteProject(request, project.id) - }, -}) +// Note: creating a challenge with `localGeoJSON`, or uploading tasks via the +// addFileTasks endpoint, does not reliably produce tasks against the pinned +// backend image (a server-side Scala collections bug silently fails async +// task import). Creating the challenge shell and its tasks directly via their +// own JSON-body endpoints (below) sidesteps that entirely and is immediate. +async function createChallenge( + request: APIRequestContext, + projectId: number, + name: string +): Promise { + const response = await request.post(`${BACKEND_URL}/api/v2/challenge`, { + headers: { apiKey: SUPER_KEY, 'Content-Type': 'application/json' }, + data: { + parent: projectId, + name, + description: 'E2E test challenge', + instruction: 'Fix the identified issue.', + difficulty: 2, + enabled: true, + featured: false, + overpassQL: '', + overpassTargetType: '', + }, + }) + if (!response.ok()) { + throw new Error(`Failed to create challenge: ${response.status()} ${await response.text()}`) + } + const body = (await response.json()) as { id: number } + return { id: body.id, name, projectId } +} + +async function createTask( + request: APIRequestContext, + challengeId: number, + name: string, + coordinates: [number, number] = [-95.454772, 37.6866588] +): Promise { + const response = await request.post(`${BACKEND_URL}/api/v2/task`, { + headers: { apiKey: SUPER_KEY, 'Content-Type': 'application/json' }, + data: { + name, + parent: challengeId, + instruction: 'Fix this point.', + geometries: { + type: 'FeatureCollection', + features: [{ type: 'Feature', geometry: { type: 'Point', coordinates }, properties: {} }], + }, + priority: 0, + }, + }) + if (!response.ok()) { + throw new Error(`Failed to create task: ${response.status()} ${await response.text()}`) + } + const body = (await response.json()) as { id: number } + return { id: body.id, name, challengeId } +} + +export const test = base.extend<{ project: TestProject; challenge: TestChallenge; task: TestTask }>( + { + project: async ({ request }, use) => { + const project = await createProject(request, uniqueName('e2e-project')) + await use(project) + await deleteProject(request, project.id) + }, + + // Deleting `project` (above) cascades to its challenges and tasks on the + // backend, so neither fixture below needs its own teardown. + challenge: async ({ request, project }, use) => { + const challenge = await createChallenge(request, project.id, uniqueName('e2e-challenge')) + await use(challenge) + }, + + task: async ({ request, challenge }, use) => { + const task = await createTask(request, challenge.id, uniqueName('e2e-task')) + await use(task) + }, + } +) export { expect } from '@playwright/test' diff --git a/e2e/profile-settings.spec.ts b/e2e/profile-settings.spec.ts new file mode 100644 index 000000000..8a37ec2fa --- /dev/null +++ b/e2e/profile-settings.spec.ts @@ -0,0 +1,54 @@ +import { expect, test } from './fixtures' + +const BACKEND_URL = 'http://localhost:9000' +const SUPER_KEY = 'super-secret-key' + +interface WhoamiUser { + id: number + settings: { + defaultBasemapId?: string | null + [key: string]: unknown + } +} + +// Note: this test does NOT reload the page to verify the setting survived a +// fresh fetch. The test harness authenticates as a synthetic backend user +// (id -999, via the MR_SUPER_KEY apiKey header) that isn't a real persisted +// DB row — every GET /whoami synthesizes a fresh default user from scratch, +// so a reload always reports defaults regardless of any PUT that came before, +// for any field. That's an inherent limitation of this synthetic identity, +// not something a real logged-in user would hit. What IS genuinely provable +// here: submitting the form calls the real update mutation (confirmed via the +// success toast, which only fires in the mutation's resolved `.then`), and +// the query cache it seeds keeps the saved value showing in the UI +// immediately afterward, without a reload. +test('a user can update their custom basemap URL setting', async ({ page, request }) => { + const whoamiResponse = await request.get(`${BACKEND_URL}/api/v2/user/whoami`, { + headers: { apiKey: SUPER_KEY }, + }) + expect(whoamiResponse.ok()).toBeTruthy() + const originalUser = (await whoamiResponse.json()) as WhoamiUser + + const newBasemapUrl = `https://example.com/tiles/${Date.now()}/{z}/{x}/{y}.png` + + await page.goto('/settings') + + const basemapUrlField = page.getByLabel('Custom Basemap URL') + await expect(basemapUrlField).toBeVisible({ timeout: 15_000 }) + + await basemapUrlField.fill(newBasemapUrl) + + const submit = page.getByRole('button', { name: 'Submit' }) + await submit.click() + + await expect(page.getByText('User settings updated')).toBeVisible({ timeout: 15_000 }) + await expect(basemapUrlField).toHaveValue(newBasemapUrl) + + // Restoring the synthetic user's settings on the real backend is a no-op + // (see note above), but this stays as a formality in case the harness ever + // points at a real persisted account. + await request.put(`${BACKEND_URL}/api/v2/user/${originalUser.id}`, { + headers: { apiKey: SUPER_KEY, 'Content-Type': 'application/json' }, + data: originalUser.settings, + }) +}) diff --git a/e2e/super-admin.spec.ts b/e2e/super-admin.spec.ts new file mode 100644 index 000000000..882478b17 --- /dev/null +++ b/e2e/super-admin.spec.ts @@ -0,0 +1,58 @@ +import { expect, test } from './fixtures' + +const BACKEND_URL = 'http://localhost:9000' +const SUPER_KEY = 'super-secret-key' + +interface WhoamiUser { + id: number + osmProfile: { + displayName: string + } +} + +// Skipped: the test harness authenticates every request as a synthetic +// backend user (id -999) via the MR_SUPER_KEY apiKey header. That grants the +// backend API full access, and PUT /superuser/-999 does add it to the +// /superusers list — but src/lib/SuperAdminGuard.tsx (the frontend's route +// guard for /super-admin/*) checks for a `grants` entry with role === -1, +// and whoami's `grants` array for this synthetic user stays empty regardless +// (confirmed: no exposed API grants a global role to it). So the frontend +// itself renders "Access Denied" for any /super-admin/* route in this +// environment, independent of anything in this repo — there's currently no +// way to represent a real super-admin-granted user without OSM OAuth or +// direct database access, neither of which the E2E harness uses. +test.skip('the super-admin users page lists the real authenticated user from the backend', async ({ + page, + request, +}) => { + // The whole browser session authenticates as this real backend user via the + // apiKey header (see e2e/fixtures.ts / .env.test), so it's a genuine row the + // super-admin users list must render from the database, not a fixture we set up. + const whoamiResponse = await request.get(`${BACKEND_URL}/api/v2/user/whoami`, { + headers: { apiKey: SUPER_KEY }, + }) + expect(whoamiResponse.ok()).toBeTruthy() + const currentUser = (await whoamiResponse.json()) as WhoamiUser + + await page.goto('/super-admin/users') + + await expect(page.getByRole('heading', { name: 'User Management' })).toBeVisible({ + timeout: 15_000, + }) + + const userRow = page.getByRole('row', { name: currentUser.osmProfile.displayName }) + await expect(userRow).toBeVisible({ timeout: 15_000 }) + await expect(userRow.getByText(`ID: ${currentUser.id}`)).toBeVisible() + await expect(userRow.getByText('super admin')).toBeVisible() + + const searchInput = page.getByPlaceholder('Search users by name or email...') + + // Filtering by the real display name keeps the row visible. + await searchInput.fill(currentUser.osmProfile.displayName) + await expect(userRow).toBeVisible() + + // An unmatched search filters the row out and shows the empty state. + await searchInput.fill('no-such-user-xyz-123') + await expect(userRow).not.toBeVisible() + await expect(page.getByText('No users found')).toBeVisible({ timeout: 15_000 }) +}) diff --git a/e2e/task-workflow.spec.ts b/e2e/task-workflow.spec.ts new file mode 100644 index 000000000..177a9f46c --- /dev/null +++ b/e2e/task-workflow.spec.ts @@ -0,0 +1,44 @@ +import { expect, test } from './fixtures' + +test('a user can open a task, view its details, and mark it as fixed', async ({ + page, + task, + challenge, +}) => { + test.setTimeout(60_000) + + await page.goto(`/tasks/${task.id}`) + + // Task details are visible: the task identity and the challenge's instructions + // (the task page shows the challenge's instruction text, substituted with task + // properties, in its "Task" instruction tab). + await expect(page.getByText(`Task #${task.id}`).first()).toBeVisible({ timeout: 15_000 }) + await expect(page.getByText('Fix the identified issue.')).toBeVisible({ timeout: 15_000 }) + + // The task auto-locks for mapping shortly after the page loads, at which point + // the completion action buttons replace the "Map this task" prompt. + const fixedButton = page.getByRole('button', { name: 'Fixed', exact: true }) + await expect(fixedButton).toBeVisible({ timeout: 20_000 }) + await fixedButton.click() + + // Confirm the status change in the completion modal + await expect(page.getByRole('heading', { name: 'Complete Task Action' })).toBeVisible({ + timeout: 10_000, + }) + await page.getByRole('button', { name: 'Complete & Continue' }).click() + + // A success toast confirms the status change was applied + await expect(page.getByText('Task marked as Fixed')).toBeVisible({ timeout: 15_000 }) + + // This is the only task in the challenge, so once it's Fixed there is no + // other task to load next, and the app returns to the challenge page. + await expect(page.getByText('No more tasks available in this challenge')).toBeVisible({ + timeout: 15_000, + }) + // The map view appends a `#zoom/lat/lng` hash once it settles, so match the + // path rather than requiring an exact end-of-string. + await expect(page).toHaveURL(new RegExp(`/challenge/${challenge.id}(#|$)`), { timeout: 20_000 }) + await expect(page.getByRole('heading', { name: challenge.name })).toBeVisible({ + timeout: 15_000, + }) +}) diff --git a/src/api/challenge/explore.test.tsx b/src/api/challenge/explore.test.tsx index 1e0bc85b9..972cb6273 100644 --- a/src/api/challenge/explore.test.tsx +++ b/src/api/challenge/explore.test.tsx @@ -2,10 +2,10 @@ import { waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { renderHookWithClient } from '@/test/queryClient' import type { - ChallengeGetResponse, - ExploreChallengesParams, - FeaturedChallengesParams, - PreferredChallengesParams, + ChallengeGetResponse, + ExploreChallengesParams, + FeaturedChallengesParams, + PreferredChallengesParams, } from '@/types/Challenge' const { apiRequestMock } = vi.hoisted(() => ({ @@ -25,19 +25,19 @@ vi.mock('@/api/client', async (importOriginal) => { import { challengeExplore } from './explore' function makeChallenge(id: number): ChallengeGetResponse { - return { id } as unknown as ChallengeGetResponse + return { id } as ChallengeGetResponse } function makeExploreParams(props: Record): ExploreChallengesParams { - return props as unknown as ExploreChallengesParams + return props as ExploreChallengesParams } function makeFeaturedParams(props: Record): FeaturedChallengesParams { - return props as unknown as FeaturedChallengesParams + return props as FeaturedChallengesParams } function makePreferredParams(props: Record): PreferredChallengesParams { - return props as unknown as PreferredChallengesParams + return props as PreferredChallengesParams } describe('challengeExplore', () => { @@ -98,7 +98,7 @@ describe('challengeExplore', () => { apiRequestMock.get.mockReturnValue({ json: () => Promise.resolve([]) }) renderHookWithClient(() => - challengeExplore.exploreChallenges(undefined as unknown as ExploreChallengesParams) + challengeExplore.exploreChallenges(undefined as ExploreChallengesParams) ) expect(apiRequestMock.get).toHaveBeenCalledWith('api/v2/challenges/exploreChallenges', { @@ -176,7 +176,7 @@ describe('challengeExplore', () => { [1, 2], { limit: 25, onlyEnabled: true }, ]) - const queryFn = options.queryFn as unknown as () => Promise + const queryFn = options.queryFn as () => Promise await queryFn() expect(apiRequestMock.get).toHaveBeenCalledWith('api/v2/challenges/listing', { diff --git a/src/api/challenge/single.test.tsx b/src/api/challenge/single.test.tsx index 62408e6f6..b1c55afc5 100644 --- a/src/api/challenge/single.test.tsx +++ b/src/api/challenge/single.test.tsx @@ -2,11 +2,11 @@ import { waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { createTestQueryClient, renderHookWithClient } from '@/test/queryClient' import type { - Challenge, - ChallengeActivityEntry, - ChallengeGetResponse, - ChallengeStatsResponse, - ChallengeTaskMarkersResponse, + Challenge, + ChallengeActivityEntry, + ChallengeGetResponse, + ChallengeStatsResponse, + ChallengeTaskMarkersResponse, } from '@/types/Challenge' import type { Task } from '@/types/Task' @@ -33,7 +33,7 @@ function makeMarkers(): ChallengeTaskMarkersResponse { { id: 2, status: 0, priority: 0 }, ], // eslint-disable-next-line @typescript-eslint/no-explicit-any - } as unknown as ChallengeTaskMarkersResponse + } as ChallengeTaskMarkersResponse } describe('patchChallengeTaskMarker', () => { @@ -136,7 +136,7 @@ describe('challengeSingle', () => { const options = challengeSingle.getChallengeOptions(7) expect(options.queryKey).toEqual(['challenge', 7]) - const queryFn = options.queryFn as unknown as () => Promise + const queryFn = options.queryFn as () => Promise const data = await queryFn() expect(apiRequestMock.get).toHaveBeenCalledWith('api/v2/challenge/7') @@ -155,7 +155,7 @@ describe('challengeSingle', () => { }) it('getChallengeStats GETs the data endpoint keyed by challengeId', async () => { - const stats = { total: 10 } as unknown as ChallengeStatsResponse + const stats = { total: 10 } as ChallengeStatsResponse apiRequestMock.get.mockReturnValue({ json: () => Promise.resolve(stats) }) const { result } = renderHookWithClient(() => challengeSingle.getChallengeStats(42)) @@ -191,7 +191,7 @@ describe('challengeSingle', () => { expect(options.queryKey).toEqual(['challenge', 'taskMarkers', 42]) expect(options.enabled).toBe(true) - const queryFn = options.queryFn as unknown as () => Promise + const queryFn = options.queryFn as () => Promise const data = await queryFn() expect(apiRequestMock.get).toHaveBeenCalledWith('api/v2/challenge/42/taskMarkers') @@ -209,7 +209,7 @@ describe('challengeSingle', () => { }) it('getRandomTask GETs a single random task and caches it', async () => { - const tasks = [{ id: 99 }] as unknown as Task[] + const tasks = [{ id: 99 }] as Task[] apiRequestMock.get.mockReturnValue({ json: () => Promise.resolve(tasks) }) const queryClient = createTestQueryClient() @@ -223,7 +223,7 @@ describe('challengeSingle', () => { }) it('fetchTasksNearby GETs the tasksNearby endpoint with default limit', async () => { - const tasks = [{ id: 1 }] as unknown as Task[] + const tasks = [{ id: 1 }] as Task[] apiRequestMock.get.mockReturnValue({ json: () => Promise.resolve(tasks) }) const result = await challengeSingle.fetchTasksNearby(42, 5) @@ -245,7 +245,7 @@ describe('challengeSingle', () => { }) it('getTasksNearby GETs the tasksNearby endpoint and caches each task by id', async () => { - const tasks = [{ id: 11 }, { id: 12 }] as unknown as Task[] + const tasks = [{ id: 11 }, { id: 12 }] as Task[] apiRequestMock.get.mockReturnValue({ json: () => Promise.resolve(tasks) }) const { result, queryClient } = renderHookWithClient(() => @@ -292,7 +292,7 @@ describe('challengeSingle', () => { result.current.mutate({ projectId: 3, - challengeData: { id: 999, name: 'My Challenge' } as unknown as Partial, + challengeData: { id: 999, name: 'My Challenge' } as Partial, }) await waitFor(() => expect(result.current.isSuccess).toBe(true)) @@ -325,7 +325,7 @@ describe('challengeSingle', () => { name: 'x', localGeoJSON: '{"type":"FeatureCollection"}', dataOriginDate: '2024-01-01', - } as unknown as Partial, + } as Partial, }) await waitFor(() => expect(result.current.isSuccess).toBe(true)) @@ -368,7 +368,7 @@ describe('challengeSingle', () => { ) const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries') - result.current.mutate({ id: 42 } as unknown as Partial) + result.current.mutate({ id: 42 } as Partial) await waitFor(() => expect(result.current.isSuccess).toBe(true)) diff --git a/src/api/index.test.ts b/src/api/index.test.ts index e62a73f5d..f5725770b 100644 --- a/src/api/index.test.ts +++ b/src/api/index.test.ts @@ -124,7 +124,7 @@ describe('apiRequest beforeRequest hook', () => { }) it('omits the apiKey header when no API key is configured', async () => { - const mutableEnv = window.env as unknown as MutableEnv + const mutableEnv = window.env as MutableEnv const originalKey = mutableEnv.VITE_SERVER_API_KEY mutableEnv.VITE_SERVER_API_KEY = undefined vi.resetModules() diff --git a/src/api/osm.ts b/src/api/osm.ts index de5405ef2..d24bfbb2e 100644 --- a/src/api/osm.ts +++ b/src/api/osm.ts @@ -258,7 +258,7 @@ export const osm = { const changesetElements = xmlDoc.querySelectorAll('changeset') changesetElements.forEach((element) => { - const changeset = normalizeXMLElement(element) as unknown as OSMChangeset + const changeset = normalizeXMLElement(element) as OSMChangeset changesets.push(changeset) }) diff --git a/src/api/project.test.tsx b/src/api/project.test.tsx index e201ba82a..15bb3c7a6 100644 --- a/src/api/project.test.tsx +++ b/src/api/project.test.tsx @@ -21,11 +21,11 @@ vi.mock('@/api/client', async (importOriginal) => { import { project } from './project' function makeProject(props: Partial & { id: number }): Project { - return { name: `project-${props.id}`, enabled: true, ...props } as unknown as Project + return { name: `project-${props.id}`, enabled: true, ...props } as Project } function makeChallenge(props: Partial & { id: number }): Challenge { - return { name: `challenge-${props.id}`, ...props } as unknown as Challenge + return { name: `challenge-${props.id}`, ...props } as Challenge } describe('project', () => { diff --git a/src/api/service/index.test.tsx b/src/api/service/index.test.tsx index 26e6d8264..17b7ebb87 100644 --- a/src/api/service/index.test.tsx +++ b/src/api/service/index.test.tsx @@ -31,7 +31,7 @@ describe('service', () => { }) it('info fetches the service info via the wired-through function', async () => { - const info = { version: '1.2.3' } as unknown as ServiceInfo + const info = { version: '1.2.3' } as ServiceInfo apiRequestMock.get.mockReturnValue({ json: () => Promise.resolve(info) }) const { result } = renderHookWithClient(() => service.info()) diff --git a/src/api/service/info.test.tsx b/src/api/service/info.test.tsx index 99fc480ee..828fd8e43 100644 --- a/src/api/service/info.test.tsx +++ b/src/api/service/info.test.tsx @@ -26,7 +26,7 @@ describe('serviceApi', () => { describe('info', () => { it('fetches the service info from the expected endpoint', async () => { - const info = { version: '1.2.3' } as unknown as ServiceInfo + const info = { version: '1.2.3' } as ServiceInfo apiRequestMock.get.mockReturnValue({ json: () => Promise.resolve(info) }) const { result } = renderHookWithClient(() => serviceApi.info()) @@ -38,7 +38,7 @@ describe('serviceApi', () => { }) it('uses the ["service", "info"] query key', async () => { - const info = { version: '1.2.3' } as unknown as ServiceInfo + const info = { version: '1.2.3' } as ServiceInfo apiRequestMock.get.mockReturnValue({ json: () => Promise.resolve(info) }) const { result, queryClient } = renderHookWithClient(() => serviceApi.info()) diff --git a/src/api/task/comments.test.tsx b/src/api/task/comments.test.tsx index c154a28e0..86aa77cca 100644 --- a/src/api/task/comments.test.tsx +++ b/src/api/task/comments.test.tsx @@ -21,7 +21,7 @@ import type { Comment } from '@/types/Comment' import { taskComments } from './comments' function makeComment(props: Partial = {}): Comment { - return { id: 1, comment: 'hello', ...props } as unknown as Comment + return { id: 1, comment: 'hello', ...props } as Comment } describe('taskComments', () => { @@ -131,7 +131,7 @@ describe('taskComments', () => { apiRequestMock.post.mockReturnValue({ json: () => Promise.resolve(newComment) }) const { result, queryClient } = renderHookWithClient(() => taskComments.useAddTaskComment()) - queryClient.setQueryData(['task', 7], { parent: 55 } as unknown as TaskGetResponse) + queryClient.setQueryData(['task', 7], { parent: 55 } as TaskGetResponse) const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries') result.current.mutate({ taskId: 7, commentText: 'hi' }) diff --git a/src/api/task/multiple.test.tsx b/src/api/task/multiple.test.tsx index 18c7c8fcd..6d59965d6 100644 --- a/src/api/task/multiple.test.tsx +++ b/src/api/task/multiple.test.tsx @@ -2,10 +2,10 @@ import { waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { createTestQueryClient, renderHookWithClient } from '@/test/queryClient' import type { - TaskGetResponse, - TaskMarkersParams, - TasksBoundingBoxQuery, - TasksInBoundsParams, + TaskGetResponse, + TaskMarkersParams, + TasksBoundingBoxQuery, + TasksInBoundsParams, } from '@/types/Task' const { apiRequestMock } = vi.hoisted(() => ({ @@ -25,7 +25,7 @@ vi.mock('@/api/client', async (importOriginal) => { import { taskMultiple } from './multiple' function makeTask(props: Partial = {}): TaskGetResponse { - return { id: 1, parent: 10, status: 0, ...props } as unknown as TaskGetResponse + return { id: 1, parent: 10, status: 0, ...props } as TaskGetResponse } describe('taskMultiple', () => { @@ -97,7 +97,7 @@ describe('taskMultiple', () => { it('fetches task markers with the converted search params', async () => { const response = { markers: [] } apiRequestMock.get.mockReturnValue({ json: () => Promise.resolve(response) }) - const params = { cid: 7 } as unknown as TaskMarkersParams + const params = { cid: 7 } as TaskMarkersParams const { result } = renderHookWithClient(() => taskMultiple.getTaskMarkers(params)) @@ -114,7 +114,7 @@ describe('taskMultiple', () => { apiRequestMock.get.mockReturnValue({ json: () => Promise.resolve({ markers: [] }) }) const { result } = renderHookWithClient(() => - taskMultiple.getTaskMarkers(null as unknown as TaskMarkersParams) + taskMultiple.getTaskMarkers(null as TaskMarkersParams) ) await waitFor(() => expect(result.current.isSuccess).toBe(true)) @@ -130,7 +130,7 @@ describe('taskMultiple', () => { it('fetches tasks in bounds with converted search params', async () => { const response = { tasks: [], total: 0 } apiRequestMock.get.mockReturnValue({ json: () => Promise.resolve(response) }) - const params = { left: 1, bottom: 2, right: 3, top: 4 } as unknown as TasksInBoundsParams + const params = { left: 1, bottom: 2, right: 3, top: 4 } as TasksInBoundsParams const { result } = renderHookWithClient(() => taskMultiple.getTasksInBounds(params)) @@ -144,7 +144,7 @@ describe('taskMultiple', () => { }) it('is disabled when options.enabled is false', () => { - const params = { left: 1, bottom: 2, right: 3, top: 4 } as unknown as TasksInBoundsParams + const params = { left: 1, bottom: 2, right: 3, top: 4 } as TasksInBoundsParams const { result } = renderHookWithClient(() => taskMultiple.getTasksInBounds(params, { enabled: false }) @@ -156,7 +156,7 @@ describe('taskMultiple', () => { it('defaults enabled to true when options are omitted', async () => { apiRequestMock.get.mockReturnValue({ json: () => Promise.resolve({ tasks: [], total: 0 }) }) - const params = { left: 1, bottom: 2, right: 3, top: 4 } as unknown as TasksInBoundsParams + const params = { left: 1, bottom: 2, right: 3, top: 4 } as TasksInBoundsParams const { result } = renderHookWithClient(() => taskMultiple.getTasksInBounds(params)) diff --git a/src/api/task/single.test.tsx b/src/api/task/single.test.tsx index 57bb60356..d7457c763 100644 --- a/src/api/task/single.test.tsx +++ b/src/api/task/single.test.tsx @@ -21,7 +21,7 @@ vi.mock('@/api/client', async (importOriginal) => { import { taskSingle } from './single' function makeTask(props: Partial = {}): TaskGetResponse { - return { id: 1, parent: 10, status: 0, priority: 1, ...props } as unknown as TaskGetResponse + return { id: 1, parent: 10, status: 0, priority: 1, ...props } as TaskGetResponse } function jsonResponse(data: T) { @@ -110,7 +110,7 @@ describe('taskSingle', () => { apiRequestMock.get.mockReturnValue({ json: () => Promise.resolve(lockedTask) }) const { result, queryClient } = renderHookWithClient(() => taskSingle.useLockTask()) - queryClient.setQueryData(['user', 'whoami'], { id: 77 } as unknown as UserWhoamiResponse) + queryClient.setQueryData(['user', 'whoami'], { id: 77 } as UserWhoamiResponse) queryClient.setQueryData(['challenge', 'taskMarkers', 30], { markers: [{ id: 20, status: 0 }], }) diff --git a/src/api/task/tags.test.tsx b/src/api/task/tags.test.tsx index 5754d6418..f018c2f54 100644 --- a/src/api/task/tags.test.tsx +++ b/src/api/task/tags.test.tsx @@ -20,7 +20,7 @@ import type { Keyword } from './tags' import { taskTags } from './tags' function makeKeyword(props: Partial = {}): Keyword { - return { id: 1, name: 'keyword' as unknown, ...props } as unknown as Keyword + return { id: 1, name: 'keyword' as unknown, ...props } as Keyword } describe('taskTags', () => { diff --git a/src/api/taskBundle/queries.test.tsx b/src/api/taskBundle/queries.test.tsx index 46bbbb4bd..c0bbe93fe 100644 --- a/src/api/taskBundle/queries.test.tsx +++ b/src/api/taskBundle/queries.test.tsx @@ -21,7 +21,7 @@ import type { TaskBundleResponse } from './queries' import { taskBundleQueries } from './queries' function makeTask(id: number): Task { - return { id } as unknown as Task + return { id } as Task } describe('taskBundleQueries', () => { diff --git a/src/api/team/index.test.tsx b/src/api/team/index.test.tsx index c0e37ab8e..46a6b8b32 100644 --- a/src/api/team/index.test.tsx +++ b/src/api/team/index.test.tsx @@ -20,11 +20,11 @@ vi.mock('@/api/client', async (importOriginal) => { import { team } from './index' function makeTeam(id: number): Team { - return { id } as unknown as Team + return { id } as Team } function makeTeamUser(id: number): TeamUser { - return { id } as unknown as TeamUser + return { id } as TeamUser } describe('team', () => { diff --git a/src/api/user/auth.test.tsx b/src/api/user/auth.test.tsx index a3d5bd8d3..8742f656a 100644 --- a/src/api/user/auth.test.tsx +++ b/src/api/user/auth.test.tsx @@ -36,7 +36,7 @@ describe('userAuth', () => { it('callback encodes the redirect_uri from window.env.VITE_APP_URL', async () => { apiRequestMock.get.mockReturnValue({ json: () => Promise.resolve({ token: 'abc' }) }) - ;(window.env as unknown as MutableEnv).VITE_APP_URL = 'https://example.test' + ;(window.env as MutableEnv).VITE_APP_URL = 'https://example.test' const result = await userAuth.callback('the-code') diff --git a/src/api/user/notifications.test.tsx b/src/api/user/notifications.test.tsx index e7e66be61..f558ceb7e 100644 --- a/src/api/user/notifications.test.tsx +++ b/src/api/user/notifications.test.tsx @@ -23,7 +23,7 @@ function makeNotification(id: number, isRead: boolean): UserNotificationsRespons return { id, isRead, - } as unknown as UserNotificationsResponse[number] + } as UserNotificationsResponse[number] } describe('userNotifications', () => { diff --git a/src/api/user/profile.test.tsx b/src/api/user/profile.test.tsx index ab1f45b0c..95acfbd23 100644 --- a/src/api/user/profile.test.tsx +++ b/src/api/user/profile.test.tsx @@ -177,7 +177,7 @@ describe('userProfile', () => { result.current.mutate({ userId: 5, - settings: { defaultEditor: 1 } as unknown as UserSettings, + settings: { defaultEditor: 1 } as UserSettings, properties: { theme: 'dark' }, }) @@ -197,7 +197,7 @@ describe('userProfile', () => { result.current.mutate({ userId: 5, - settings: { defaultEditor: 1 } as unknown as UserSettings, + settings: { defaultEditor: 1 } as UserSettings, }) await waitFor(() => expect(result.current.isSuccess).toBe(true)) diff --git a/src/components/Pages/ExploreChallengesPage/FilterBar/index.tsx b/src/components/Pages/ExploreChallengesPage/FilterBar/index.tsx index 927411d6f..04f0f941c 100644 --- a/src/components/Pages/ExploreChallengesPage/FilterBar/index.tsx +++ b/src/components/Pages/ExploreChallengesPage/FilterBar/index.tsx @@ -47,7 +47,7 @@ export const FilterBar = () => { keywords: keywords && keywords !== '' ? keywords : undefined, difficulty: difficulty !== undefined - ? reverseDifficultyMap[difficulty as unknown as number] + ? reverseDifficultyMap[difficulty as number] : undefined, viewMode: viewMode !== 'grid-map' ? viewMode : undefined, }), diff --git a/src/components/Pages/ManagementPages/ManageChallengeNew/ChallengeForm.test.tsx b/src/components/Pages/ManagementPages/ManageChallengeNew/ChallengeForm.test.tsx index e5a124674..a4453bff6 100644 --- a/src/components/Pages/ManagementPages/ManageChallengeNew/ChallengeForm.test.tsx +++ b/src/components/Pages/ManagementPages/ManageChallengeNew/ChallengeForm.test.tsx @@ -37,7 +37,7 @@ vi.mock('@/contexts/ChallengeFormContext', () => ({ useChallengeFormContext: useChallengeFormContextMock, })) -const fakeUser = { osmProfile: { id: 1, displayName: 'TestUser' }, grants: [] } as unknown as User +const fakeUser = { osmProfile: { id: 1, displayName: 'TestUser' }, grants: [] } as User afterEach(() => cleanup()) @@ -165,7 +165,7 @@ describe('ChallengeForm validation (edit mode)', () => { difficulty: 2, overpassQL: 'existing overpass query', remoteGeoJson: '', - } as unknown as Challenge + } as Challenge it('submits successfully without requiring project selection or the agreement checkbox', async () => { const user = userEvent.setup() diff --git a/src/components/Pages/ManagementPages/ManageProjectNew/ProjectForm.test.tsx b/src/components/Pages/ManagementPages/ManageProjectNew/ProjectForm.test.tsx index 67f832b57..d8f834617 100644 --- a/src/components/Pages/ManagementPages/ManageProjectNew/ProjectForm.test.tsx +++ b/src/components/Pages/ManagementPages/ManageProjectNew/ProjectForm.test.tsx @@ -12,7 +12,7 @@ vi.mock('@/contexts/AuthContext', () => ({ useAuthContext: useAuthContextMock, })) -const regularUser = { grants: [] } as unknown as User +const regularUser = { grants: [] } as User afterEach(() => { cleanup() diff --git a/src/components/Pages/ManagementPages/ManageTaskEdit/TaskForm.test.tsx b/src/components/Pages/ManagementPages/ManageTaskEdit/TaskForm.test.tsx index 8402553e1..246a4109d 100644 --- a/src/components/Pages/ManagementPages/ManageTaskEdit/TaskForm.test.tsx +++ b/src/components/Pages/ManagementPages/ManageTaskEdit/TaskForm.test.tsx @@ -10,7 +10,7 @@ const baseTask = { geometries: { type: 'Point', coordinates: [0, 0] }, status: 0, errorTags: '', -} as unknown as TaskGetResponse +} as TaskGetResponse afterEach(() => cleanup()) diff --git a/src/components/Pages/ManagementPages/TaskPrioritizationPage/Editor/BoundsDrawControl.tsx b/src/components/Pages/ManagementPages/TaskPrioritizationPage/Editor/BoundsDrawControl.tsx index 7e3e14062..ecaac2be0 100644 --- a/src/components/Pages/ManagementPages/TaskPrioritizationPage/Editor/BoundsDrawControl.tsx +++ b/src/components/Pages/ManagementPages/TaskPrioritizationPage/Editor/BoundsDrawControl.tsx @@ -2,12 +2,12 @@ import { Eraser, Hand, MousePointer2, Pencil, Square, Trash2 } from 'lucide-reac import { useEffect, useRef, useState } from 'react' import type { MapRef } from 'react-map-gl/maplibre' import { - type GeoJSONStoreFeatures, - type HexColor, - TerraDraw, - TerraDrawPolygonMode, - TerraDrawRectangleMode, - TerraDrawSelectMode, + type GeoJSONStoreFeatures, + type HexColor, + TerraDraw, + TerraDrawPolygonMode, + TerraDrawRectangleMode, + TerraDrawSelectMode, } from 'terra-draw' import { TerraDrawMapLibreGLAdapter } from 'terra-draw-maplibre-gl-adapter' import { Button } from '@/components/ui/Button' @@ -73,7 +73,7 @@ const normalizeSeedFeature = (feature: GeoJSON.Feature): GeoJSONStoreFeatures => ...feature, id: feature.id ?? crypto.randomUUID(), properties: { ...(feature.properties ?? {}), mode }, - } as unknown as GeoJSONStoreFeatures + } as GeoJSONStoreFeatures } /** diff --git a/src/components/Pages/SettingsPage/UserSettingsForm/index.tsx b/src/components/Pages/SettingsPage/UserSettingsForm/index.tsx index 1b3b491cb..d1c7694e5 100644 --- a/src/components/Pages/SettingsPage/UserSettingsForm/index.tsx +++ b/src/components/Pages/SettingsPage/UserSettingsForm/index.tsx @@ -2,10 +2,11 @@ import { zodResolver } from '@hookform/resolvers/zod' import { useForm } from 'react-hook-form' import { toast } from 'sonner' import type { z } from 'zod' +import { api } from '@/api' import { FieldGroup } from '@/components/ui/Field' import { Form } from '@/components/ui/Form' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/Tabs' -import type { User } from '@/types/User' +import type { User, UserSettings } from '@/types/User' import { ApiSettings } from './ApiSettings' import { formSchema } from './formSchema' import { GeneralSettings } from './GeneralSettings' @@ -24,14 +25,17 @@ export const UserSettingsForm = ({ user }: { user: User }) => { }, }) - const onSubmit = async () => { - return new Promise((resolve) => { - setTimeout(() => { - resolve(true) - }, 1000) - }).then(() => { - toast('User settings updated') + const { mutateAsync: updateUserSettings } = api.user.useUpdateUserSettings() + + const onSubmit = async (values: z.infer) => { + // `defaultBasemap`'s zod schema is a bare `.refine()` with no base type, so + // it infers as `unknown` even though the form only ever produces a number + // (see formSchema.ts) — narrow it back to the shape the API expects. + await updateUserSettings({ + userId: user.id, + settings: values as UserSettings, }) + toast('User settings updated') } return ( diff --git a/src/components/Pages/TaskEditPage/TaskMap/TaskEditMapContext.tsx b/src/components/Pages/TaskEditPage/TaskMap/TaskEditMapContext.tsx index 4c90b456e..35a785a3f 100644 --- a/src/components/Pages/TaskEditPage/TaskMap/TaskEditMapContext.tsx +++ b/src/components/Pages/TaskEditPage/TaskMap/TaskEditMapContext.tsx @@ -1,13 +1,13 @@ import bbox from '@turf/bbox' import { - createContext, - type ReactNode, - useCallback, - useContext, - useEffect, - useMemo, - useRef, - useState, + createContext, + type ReactNode, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, } from 'react' import type { MapMouseEvent, MapRef } from 'react-map-gl/maplibre' import Supercluster from 'supercluster' @@ -18,10 +18,10 @@ import { CLUSTER_RADIUS_PX, LAYER_IDS } from '@/components/Map/TaskMarkers/const import { createMarkerIcons } from '@/components/Map/TaskMarkers/createMarkerIcons' import { createSpiderGroup, detectVisualOverlaps } from '@/components/Map/TaskMarkers/spiderUtils' import { - calculateTaskCount, - convertTaskMarkersToGeoJSON, - isTaskEligibleForBundle, - processMarkersData, + calculateTaskCount, + convertTaskMarkersToGeoJSON, + isTaskEligibleForBundle, + processMarkersData, } from '@/components/Map/TaskMarkers/utils' import { useTaskBundleContext } from '@/components/Pages/TaskEditPage/contexts/TaskBundleContext' import { useTaskContext } from '@/components/Pages/TaskEditPage/contexts/TaskContext' @@ -352,7 +352,7 @@ export const TaskEditMapProvider = ({ children }: { children: ReactNode }) => { map: (props: PointProperties) => ({ taskCount: props.isOverlapping && props.overlapTaskCount ? props.overlapTaskCount : 1, - }) as unknown as ClusterProperties, + }) as ClusterProperties, reduce: (accumulated: ClusterProperties, props: ClusterProperties) => { accumulated.taskCount = (accumulated.taskCount || 0) + (props.taskCount || 1) }, diff --git a/src/components/Pages/TaskEditPage/TaskMap/TaskGeometryLayer.tsx b/src/components/Pages/TaskEditPage/TaskMap/TaskGeometryLayer.tsx index 3042aa146..d1ee88a22 100644 --- a/src/components/Pages/TaskEditPage/TaskMap/TaskGeometryLayer.tsx +++ b/src/components/Pages/TaskEditPage/TaskMap/TaskGeometryLayer.tsx @@ -141,7 +141,7 @@ export const TaskGeometryLayer = () => { )} {hasPolygon && ( @@ -149,7 +149,7 @@ export const TaskGeometryLayer = () => { id={`${layerId}-fill-outline`} type="line" filter={['match', ['geometry-type'], ['Polygon', 'MultiPolygon'], true, false]} - paint={getLinePaint() as unknown as maplibregl.LineLayerSpecification['paint']} + paint={getLinePaint() as maplibregl.LineLayerSpecification['paint']} /> )} {hasLineString && ( @@ -157,14 +157,14 @@ export const TaskGeometryLayer = () => { id={`${layerId}-line`} type="line" filter={['match', ['geometry-type'], ['LineString', 'MultiLineString'], true, false]} - paint={getLinePaint() as unknown as maplibregl.LineLayerSpecification['paint']} + paint={getLinePaint() as maplibregl.LineLayerSpecification['paint']} /> )} {hasPoint && ( )} diff --git a/src/components/Pages/TaskEditPage/TaskMap/useAllMarkersMap.test.tsx b/src/components/Pages/TaskEditPage/TaskMap/useAllMarkersMap.test.tsx index f48ded6db..4e98bef15 100644 --- a/src/components/Pages/TaskEditPage/TaskMap/useAllMarkersMap.test.tsx +++ b/src/components/Pages/TaskEditPage/TaskMap/useAllMarkersMap.test.tsx @@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest' import type { TaskMarker } from '@/types/Task' import { useAllMarkersMap } from './useAllMarkersMap' -const makeMarker = (id: number): TaskMarker => ({ id }) as unknown as TaskMarker +const makeMarker = (id: number): TaskMarker => ({ id }) as TaskMarker describe('useAllMarkersMap', () => { it('returns an empty map when there are no markers or overlaps', () => { diff --git a/src/components/Pages/TaskEditPage/TaskMap/useLassoBundleSync.test.tsx b/src/components/Pages/TaskEditPage/TaskMap/useLassoBundleSync.test.tsx index 1f829e7a1..c4d0d1acf 100644 --- a/src/components/Pages/TaskEditPage/TaskMap/useLassoBundleSync.test.tsx +++ b/src/components/Pages/TaskEditPage/TaskMap/useLassoBundleSync.test.tsx @@ -45,7 +45,7 @@ const setContext = ({ }) => { useTaskMapContextMock.mockReturnValue({ selectedTaskIds, clearSelection }) useTaskBundleContextMock.mockReturnValue({ activeBundle, setActiveBundle }) - useTaskContextMock.mockReturnValue({ task: { id: primaryTaskId } as unknown as Task }) + useTaskContextMock.mockReturnValue({ task: { id: primaryTaskId } as Task }) getTaskMock.mockReturnValue({ data: primaryTaskData }) } @@ -71,7 +71,7 @@ describe('useLassoBundleSync', () => { it('creates a pending bundle from the primary task and the selection when there is no active bundle', () => { const clearSelection = vi.fn() const setActiveBundle = vi.fn() - const primaryTaskData = { id: 1, name: 'primary' } as unknown as Task + const primaryTaskData = { id: 1, name: 'primary' } as Task setContext({ selectedTaskIds: new Set([2, 3]), clearSelection, diff --git a/src/components/Pages/TaskEditPage/TaskMap/useLassoEvents.test.tsx b/src/components/Pages/TaskEditPage/TaskMap/useLassoEvents.test.tsx index a43054006..ff3831e9a 100644 --- a/src/components/Pages/TaskEditPage/TaskMap/useLassoEvents.test.tsx +++ b/src/components/Pages/TaskEditPage/TaskMap/useLassoEvents.test.tsx @@ -51,7 +51,7 @@ const makeMarker = ( bundleId: null, lockedBy: null, ...overrides, - }) as unknown as TaskMarker + }) as TaskMarker const makeCanvasMap = () => { const canvas = document.createElement('canvas') @@ -130,7 +130,7 @@ const setContext = ({ cancelDrawing, }) useTaskContextMock.mockReturnValue({ - task: { id: taskId, parent: challengeId, bundleId: taskBundleId } as unknown as Task, + task: { id: taskId, parent: challengeId, bundleId: taskBundleId } as Task, }) useTaskBundleContextMock.mockReturnValue({ activeBundle }) useAuthContextMock.mockReturnValue({ @@ -156,7 +156,7 @@ describe('useLassoEvents', () => { setContext({ drawingMode: null, setIsDrawing, - mapRefCurrent: mapRefWithFakeMap(fakeMap) as unknown as FakeMap, + mapRefCurrent: mapRefWithFakeMap(fakeMap) as FakeMap, }) renderHook(() => useLassoEvents()) @@ -174,7 +174,7 @@ describe('useLassoEvents', () => { drawingMode: 'select', setIsDrawing, setLassoPolygon, - mapRefCurrent: mapRefWithFakeMap(fakeMap) as unknown as FakeMap, + mapRefCurrent: mapRefWithFakeMap(fakeMap) as FakeMap, }) renderHook(() => useLassoEvents()) @@ -192,7 +192,7 @@ describe('useLassoEvents', () => { setContext({ drawingMode: 'select', setIsDrawing, - mapRefCurrent: mapRefWithFakeMap(fakeMap) as unknown as FakeMap, + mapRefCurrent: mapRefWithFakeMap(fakeMap) as FakeMap, }) renderHook(() => useLassoEvents()) @@ -208,7 +208,7 @@ describe('useLassoEvents', () => { setContext({ drawingMode: 'select', setLassoPolygon, - mapRefCurrent: mapRefWithFakeMap(fakeMap) as unknown as FakeMap, + mapRefCurrent: mapRefWithFakeMap(fakeMap) as FakeMap, }) renderHook(() => useLassoEvents()) @@ -255,7 +255,7 @@ describe('useLassoEvents', () => { userId: 1, activeBundle: { bundleId: 9, taskIds: [6], name: 'b' }, markers, - mapRefCurrent: mapRefWithFakeMap(fakeMap) as unknown as FakeMap, + mapRefCurrent: mapRefWithFakeMap(fakeMap) as FakeMap, }) renderHook(() => useLassoEvents()) @@ -287,7 +287,7 @@ describe('useLassoEvents', () => { setSelectedTaskIds, taskId: 999, markers, - mapRefCurrent: mapRefWithFakeMap(fakeMap) as unknown as FakeMap, + mapRefCurrent: mapRefWithFakeMap(fakeMap) as FakeMap, }) renderHook(() => useLassoEvents()) @@ -316,7 +316,7 @@ describe('useLassoEvents', () => { setSelectedTaskIds, taskId: 999, markers, - mapRefCurrent: mapRefWithFakeMap(fakeMap) as unknown as FakeMap, + mapRefCurrent: mapRefWithFakeMap(fakeMap) as FakeMap, }) renderHook(() => useLassoEvents()) @@ -340,7 +340,7 @@ describe('useLassoEvents', () => { drawingMode: 'select', setSelectedTaskIds, markers: [makeMarker(1, 5, 5)], - mapRefCurrent: mapRefWithFakeMap(fakeMap) as unknown as FakeMap, + mapRefCurrent: mapRefWithFakeMap(fakeMap) as FakeMap, }) renderHook(() => useLassoEvents()) @@ -357,7 +357,7 @@ describe('useLassoEvents', () => { setContext({ drawingMode: 'select', cancelDrawing, - mapRefCurrent: mapRefWithFakeMap(fakeMap) as unknown as FakeMap, + mapRefCurrent: mapRefWithFakeMap(fakeMap) as FakeMap, }) renderHook(() => useLassoEvents()) @@ -373,7 +373,7 @@ describe('useLassoEvents', () => { setContext({ drawingMode: null, cancelDrawing, - mapRefCurrent: mapRefWithFakeMap(fakeMap) as unknown as FakeMap, + mapRefCurrent: mapRefWithFakeMap(fakeMap) as FakeMap, }) renderHook(() => useLassoEvents()) @@ -389,7 +389,7 @@ describe('useLassoEvents', () => { setContext({ drawingMode: 'select', setIsDrawing, - mapRefCurrent: mapRefWithFakeMap(fakeMap) as unknown as FakeMap, + mapRefCurrent: mapRefWithFakeMap(fakeMap) as FakeMap, }) const { unmount } = renderHook(() => useLassoEvents()) diff --git a/src/components/Pages/TaskEditPage/TaskMap/useMapNavigation.test.tsx b/src/components/Pages/TaskEditPage/TaskMap/useMapNavigation.test.tsx index 51fc7503c..df8fc9447 100644 --- a/src/components/Pages/TaskEditPage/TaskMap/useMapNavigation.test.tsx +++ b/src/components/Pages/TaskEditPage/TaskMap/useMapNavigation.test.tsx @@ -22,7 +22,7 @@ vi.mock('@/components/Pages/TaskEditPage/contexts/TaskMapContext', () => ({ import { useMapNavigation } from './useMapNavigation' const makeMarker = (id: number, lng: number, lat: number): TaskMarker => - ({ id, location: { lng, lat } }) as unknown as TaskMarker + ({ id, location: { lng, lat } }) as TaskMarker const makeFakeMap = () => ({ jumpTo: vi.fn(), @@ -35,7 +35,7 @@ const setContext = ( activeBundle: TaskBundle | null ) => { useTaskMapContextMock.mockReturnValue({ map }) - useTaskContextMock.mockReturnValue({ task: { id: taskId } as unknown as Task }) + useTaskContextMock.mockReturnValue({ task: { id: taskId } as Task }) useTaskBundleContextMock.mockReturnValue({ activeBundle }) } diff --git a/src/components/Pages/TaskEditPage/TaskMap/useMarkerVisibility.test.tsx b/src/components/Pages/TaskEditPage/TaskMap/useMarkerVisibility.test.tsx index 3f6ae605f..6b1f499d7 100644 --- a/src/components/Pages/TaskEditPage/TaskMap/useMarkerVisibility.test.tsx +++ b/src/components/Pages/TaskEditPage/TaskMap/useMarkerVisibility.test.tsx @@ -12,7 +12,7 @@ vi.mock('@/components/Pages/TaskEditPage/contexts/TaskMapContext', () => ({ import { useMarkerVisibility } from './useMarkerVisibility' -const marker = { id: 1 } as unknown as TaskMarker +const marker = { id: 1 } as TaskMarker interface ContextOverrides { selectedMarker?: TaskMarker | null @@ -49,7 +49,7 @@ describe('useMarkerVisibility', () => { const { rerender } = renderHook(() => useMarkerVisibility()) setContext({ - selectedMarker: { ...marker, id: 2 } as unknown as TaskMarker, + selectedMarker: { ...marker, id: 2 } as TaskMarker, markersHidden: true, setMarkersHidden, }) diff --git a/src/hooks/useWebSocketEvents.test.tsx b/src/hooks/useWebSocketEvents.test.tsx index 75f30d008..a03380b2b 100644 --- a/src/hooks/useWebSocketEvents.test.tsx +++ b/src/hooks/useWebSocketEvents.test.tsx @@ -30,7 +30,7 @@ vi.mock('@/lib/logger', () => ({ import { useWebSocketEvents } from './useWebSocketEvents' function makeUser(id: number): User { - return { id } as unknown as User + return { id } as User } function setLastMessage(message: WebSocketMessageTypes | null) { @@ -148,9 +148,9 @@ describe('useWebSocketEvents', () => { queryClient.setQueryData(['task', 5], { id: 5, status: 0, - } as unknown as TaskGetResponse) + } as TaskGetResponse) queryClient.setQueryData(['challenge', 'taskMarkers', 3], { - markers: [{ id: 5, status: 0 } as unknown as ChallengeTaskMarkersResponse['markers'][number]], + markers: [{ id: 5, status: 0 } as ChallengeTaskMarkersResponse['markers'][number]], overlaps: [], }) const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries') @@ -190,7 +190,7 @@ describe('useWebSocketEvents', () => { queryClient.setQueryData(['task', 5], { id: 5, status: 1, - } as unknown as TaskGetResponse) + } as TaskGetResponse) const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries') act(() => { @@ -218,7 +218,7 @@ describe('useWebSocketEvents', () => { const { rerender, queryClient } = renderHookWithClient(() => useWebSocketEvents()) queryClient.setQueryData(['challenge', 'taskMarkers', 3], { markers: [ - { id: 5, lockedBy: null } as unknown as ChallengeTaskMarkersResponse['markers'][number], + { id: 5, lockedBy: null } as ChallengeTaskMarkersResponse['markers'][number], ], overlaps: [], }) @@ -357,7 +357,7 @@ describe('useWebSocketEvents', () => { const malformedTaskMessage = { messageType: 'task-completed', data: {}, - } as unknown as WebSocketMessageTypes + } as WebSocketMessageTypes const { rerender } = renderHookWithClient(() => useWebSocketEvents()) diff --git a/src/lib/challengePermissions.test.ts b/src/lib/challengePermissions.test.ts index 20e757f3e..fbd908df5 100644 --- a/src/lib/challengePermissions.test.ts +++ b/src/lib/challengePermissions.test.ts @@ -17,14 +17,14 @@ function makeUser(props: { osmId?: number; grants?: GrantFixture[] } = {}): User role: g.role, target: g.targetId != null ? { objectId: g.targetId } : undefined, })), - } as unknown as User + } as User } function makeChallenge(props: { owner?: number; parent?: number } = {}): Challenge { return { owner: props.owner, parent: props.parent, - } as unknown as Challenge + } as Challenge } describe('canManageChallenge', () => { diff --git a/src/lib/localGeoJSON.ts b/src/lib/localGeoJSON.ts index 3f49d2968..1ec3139e6 100644 --- a/src/lib/localGeoJSON.ts +++ b/src/lib/localGeoJSON.ts @@ -24,5 +24,5 @@ export const detectLocalGeoJSONSubmission = async (file: File): Promise { try { - const windowWithPlugin = window as unknown as Window & Record + const windowWithPlugin = window as Window & Record const pluginModule = windowWithPlugin[globalName] as | { default?: Plugin; plugin?: Plugin } | Plugin diff --git a/src/routes/_app/challenge/[$challengeId]/index.test.ts b/src/routes/_app/challenge/[$challengeId]/index.test.ts index 5306da4b8..e7dd42012 100644 --- a/src/routes/_app/challenge/[$challengeId]/index.test.ts +++ b/src/routes/_app/challenge/[$challengeId]/index.test.ts @@ -4,7 +4,7 @@ import { Route } from './index.tsx' // The route's search schema (`comments`) is a local, unexported const, so it's // reached the same way the router itself reaches it: off `Route.options`. -const schema = Route.options.validateSearch as unknown as z.ZodType<{ comments?: number }> +const schema = Route.options.validateSearch as z.ZodType<{ comments?: number }> describe('challenge route search schema', () => { it('accepts an empty search (comments is optional)', () => { diff --git a/src/routes/_app/index.test.ts b/src/routes/_app/index.test.ts index 4d7953d90..84ab5d495 100644 --- a/src/routes/_app/index.test.ts +++ b/src/routes/_app/index.test.ts @@ -22,7 +22,7 @@ type ChallengesSearch = { // The route's search schema is a local, unexported const, so it's reached the // same way the router itself reaches it: off `Route.options`. -const schema = Route.options.validateSearch as unknown as z.ZodType +const schema = Route.options.validateSearch as z.ZodType describe('explore challenges route search schema', () => { it('accepts an empty search', () => { diff --git a/src/routes/_app/manage/challenge/new.test.ts b/src/routes/_app/manage/challenge/new.test.ts index 16cbf7a28..edcb97f12 100644 --- a/src/routes/_app/manage/challenge/new.test.ts +++ b/src/routes/_app/manage/challenge/new.test.ts @@ -4,7 +4,7 @@ import { Route } from './new.tsx' // The route's search schema (`projectId`) is a local, unexported const, so // it's reached the same way the router itself reaches it: off `Route.options`. -const schema = Route.options.validateSearch as unknown as z.ZodType<{ projectId?: number }> +const schema = Route.options.validateSearch as z.ZodType<{ projectId?: number }> describe('manage challenge new route search schema', () => { it('accepts an empty search (projectId is optional)', () => { diff --git a/src/routes/_app/notifications.test.ts b/src/routes/_app/notifications.test.ts index 8714c669b..71f80c6dc 100644 --- a/src/routes/_app/notifications.test.ts +++ b/src/routes/_app/notifications.test.ts @@ -15,7 +15,7 @@ type NotificationsSearch = { // The route's search schema is a local, unexported const, so it's reached the // same way the router itself reaches it: off `Route.options`. -const schema = Route.options.validateSearch as unknown as z.ZodType +const schema = Route.options.validateSearch as z.ZodType describe('notifications route search schema', () => { it('accepts an empty search', () => { diff --git a/src/routes/_app/tasks/[$taskId]/index.test.ts b/src/routes/_app/tasks/[$taskId]/index.test.ts index 5469e679b..58f9ff89c 100644 --- a/src/routes/_app/tasks/[$taskId]/index.test.ts +++ b/src/routes/_app/tasks/[$taskId]/index.test.ts @@ -4,7 +4,7 @@ import { Route } from './index.tsx' // The route's search schema (`tab`) is a local, unexported const, so it's // reached the same way the router itself reaches it: off `Route.options`. -const schema = Route.options.validateSearch as unknown as z.ZodType<{ +const schema = Route.options.validateSearch as z.ZodType<{ tab?: 'task' | 'properties' | 'comments' | 'osm' }> diff --git a/src/test/setup.ts b/src/test/setup.ts index 4c45aec35..5abb1edfd 100644 --- a/src/test/setup.ts +++ b/src/test/setup.ts @@ -2,5 +2,5 @@ // boots. Tests have no env.json, so seed window.env from Vite's import.meta.env // (populated from .env.test). The node environment used by *.test.ts has no // window, so define one first. -globalThis.window ??= globalThis as unknown as Window & typeof globalThis +globalThis.window ??= globalThis as Window & typeof globalThis window.env = { ...import.meta.env } as AppEnv From 4b7ead1a549af1d7a3cfc218b316b54b0cc2e153 Mon Sep 17 00:00:00 2001 From: Collin Beczak Date: Thu, 16 Jul 2026 18:26:45 -0500 Subject: [PATCH 4/6] run formatting --- src/api/challenge/explore.test.tsx | 8 +++---- src/api/challenge/single.test.tsx | 10 ++++---- src/api/task/multiple.test.tsx | 8 +++---- .../ExploreChallengesPage/FilterBar/index.tsx | 4 +--- .../Editor/BoundsDrawControl.tsx | 12 +++++----- .../TaskMap/TaskEditMapContext.tsx | 24 +++++++++---------- src/hooks/useWebSocketEvents.test.tsx | 4 +--- 7 files changed, 33 insertions(+), 37 deletions(-) diff --git a/src/api/challenge/explore.test.tsx b/src/api/challenge/explore.test.tsx index 972cb6273..2240a07ea 100644 --- a/src/api/challenge/explore.test.tsx +++ b/src/api/challenge/explore.test.tsx @@ -2,10 +2,10 @@ import { waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { renderHookWithClient } from '@/test/queryClient' import type { - ChallengeGetResponse, - ExploreChallengesParams, - FeaturedChallengesParams, - PreferredChallengesParams, + ChallengeGetResponse, + ExploreChallengesParams, + FeaturedChallengesParams, + PreferredChallengesParams, } from '@/types/Challenge' const { apiRequestMock } = vi.hoisted(() => ({ diff --git a/src/api/challenge/single.test.tsx b/src/api/challenge/single.test.tsx index b1c55afc5..e5210df40 100644 --- a/src/api/challenge/single.test.tsx +++ b/src/api/challenge/single.test.tsx @@ -2,11 +2,11 @@ import { waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { createTestQueryClient, renderHookWithClient } from '@/test/queryClient' import type { - Challenge, - ChallengeActivityEntry, - ChallengeGetResponse, - ChallengeStatsResponse, - ChallengeTaskMarkersResponse, + Challenge, + ChallengeActivityEntry, + ChallengeGetResponse, + ChallengeStatsResponse, + ChallengeTaskMarkersResponse, } from '@/types/Challenge' import type { Task } from '@/types/Task' diff --git a/src/api/task/multiple.test.tsx b/src/api/task/multiple.test.tsx index 6d59965d6..0697c6972 100644 --- a/src/api/task/multiple.test.tsx +++ b/src/api/task/multiple.test.tsx @@ -2,10 +2,10 @@ import { waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { createTestQueryClient, renderHookWithClient } from '@/test/queryClient' import type { - TaskGetResponse, - TaskMarkersParams, - TasksBoundingBoxQuery, - TasksInBoundsParams, + TaskGetResponse, + TaskMarkersParams, + TasksBoundingBoxQuery, + TasksInBoundsParams, } from '@/types/Task' const { apiRequestMock } = vi.hoisted(() => ({ diff --git a/src/components/Pages/ExploreChallengesPage/FilterBar/index.tsx b/src/components/Pages/ExploreChallengesPage/FilterBar/index.tsx index 04f0f941c..975f38b49 100644 --- a/src/components/Pages/ExploreChallengesPage/FilterBar/index.tsx +++ b/src/components/Pages/ExploreChallengesPage/FilterBar/index.tsx @@ -46,9 +46,7 @@ export const FilterBar = () => { osm_id: locationOsmId ?? undefined, keywords: keywords && keywords !== '' ? keywords : undefined, difficulty: - difficulty !== undefined - ? reverseDifficultyMap[difficulty as number] - : undefined, + difficulty !== undefined ? reverseDifficultyMap[difficulty as number] : undefined, viewMode: viewMode !== 'grid-map' ? viewMode : undefined, }), hash: true, diff --git a/src/components/Pages/ManagementPages/TaskPrioritizationPage/Editor/BoundsDrawControl.tsx b/src/components/Pages/ManagementPages/TaskPrioritizationPage/Editor/BoundsDrawControl.tsx index ecaac2be0..4e09fccfc 100644 --- a/src/components/Pages/ManagementPages/TaskPrioritizationPage/Editor/BoundsDrawControl.tsx +++ b/src/components/Pages/ManagementPages/TaskPrioritizationPage/Editor/BoundsDrawControl.tsx @@ -2,12 +2,12 @@ import { Eraser, Hand, MousePointer2, Pencil, Square, Trash2 } from 'lucide-reac import { useEffect, useRef, useState } from 'react' import type { MapRef } from 'react-map-gl/maplibre' import { - type GeoJSONStoreFeatures, - type HexColor, - TerraDraw, - TerraDrawPolygonMode, - TerraDrawRectangleMode, - TerraDrawSelectMode, + type GeoJSONStoreFeatures, + type HexColor, + TerraDraw, + TerraDrawPolygonMode, + TerraDrawRectangleMode, + TerraDrawSelectMode, } from 'terra-draw' import { TerraDrawMapLibreGLAdapter } from 'terra-draw-maplibre-gl-adapter' import { Button } from '@/components/ui/Button' diff --git a/src/components/Pages/TaskEditPage/TaskMap/TaskEditMapContext.tsx b/src/components/Pages/TaskEditPage/TaskMap/TaskEditMapContext.tsx index 35a785a3f..7f5578215 100644 --- a/src/components/Pages/TaskEditPage/TaskMap/TaskEditMapContext.tsx +++ b/src/components/Pages/TaskEditPage/TaskMap/TaskEditMapContext.tsx @@ -1,13 +1,13 @@ import bbox from '@turf/bbox' import { - createContext, - type ReactNode, - useCallback, - useContext, - useEffect, - useMemo, - useRef, - useState, + createContext, + type ReactNode, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, } from 'react' import type { MapMouseEvent, MapRef } from 'react-map-gl/maplibre' import Supercluster from 'supercluster' @@ -18,10 +18,10 @@ import { CLUSTER_RADIUS_PX, LAYER_IDS } from '@/components/Map/TaskMarkers/const import { createMarkerIcons } from '@/components/Map/TaskMarkers/createMarkerIcons' import { createSpiderGroup, detectVisualOverlaps } from '@/components/Map/TaskMarkers/spiderUtils' import { - calculateTaskCount, - convertTaskMarkersToGeoJSON, - isTaskEligibleForBundle, - processMarkersData, + calculateTaskCount, + convertTaskMarkersToGeoJSON, + isTaskEligibleForBundle, + processMarkersData, } from '@/components/Map/TaskMarkers/utils' import { useTaskBundleContext } from '@/components/Pages/TaskEditPage/contexts/TaskBundleContext' import { useTaskContext } from '@/components/Pages/TaskEditPage/contexts/TaskContext' diff --git a/src/hooks/useWebSocketEvents.test.tsx b/src/hooks/useWebSocketEvents.test.tsx index a03380b2b..de800ce0b 100644 --- a/src/hooks/useWebSocketEvents.test.tsx +++ b/src/hooks/useWebSocketEvents.test.tsx @@ -217,9 +217,7 @@ describe('useWebSocketEvents', () => { const { rerender, queryClient } = renderHookWithClient(() => useWebSocketEvents()) queryClient.setQueryData(['challenge', 'taskMarkers', 3], { - markers: [ - { id: 5, lockedBy: null } as ChallengeTaskMarkersResponse['markers'][number], - ], + markers: [{ id: 5, lockedBy: null } as ChallengeTaskMarkersResponse['markers'][number]], overlaps: [], }) From 60145eb9c832972f0cc5f00005bb66fee150e8eb Mon Sep 17 00:00:00 2001 From: Collin Beczak Date: Thu, 16 Jul 2026 19:11:18 -0500 Subject: [PATCH 5/6] fix types --- src/api/challenge/single.test.tsx | 4 +-- src/api/service/index.test.tsx | 2 +- src/api/service/info.test.tsx | 4 +-- src/api/task/multiple.test.tsx | 4 +-- .../ExploreChallengesPage/FilterBar/index.tsx | 4 ++- .../ManageChallengeNew/ChallengeForm.test.tsx | 2 +- .../ManageProjectNew/ProjectForm.test.tsx | 2 +- .../ManageTaskEdit/TaskForm.test.tsx | 2 +- .../TaskMap/useLassoEvents.test.tsx | 25 ++++++++++--------- src/plugins/DynamicPluginLoader.ts | 2 +- 10 files changed, 26 insertions(+), 25 deletions(-) diff --git a/src/api/challenge/single.test.tsx b/src/api/challenge/single.test.tsx index e5210df40..a46eed87a 100644 --- a/src/api/challenge/single.test.tsx +++ b/src/api/challenge/single.test.tsx @@ -155,7 +155,7 @@ describe('challengeSingle', () => { }) it('getChallengeStats GETs the data endpoint keyed by challengeId', async () => { - const stats = { total: 10 } as ChallengeStatsResponse + const stats = { total: 10 } as unknown as ChallengeStatsResponse apiRequestMock.get.mockReturnValue({ json: () => Promise.resolve(stats) }) const { result } = renderHookWithClient(() => challengeSingle.getChallengeStats(42)) @@ -325,7 +325,7 @@ describe('challengeSingle', () => { name: 'x', localGeoJSON: '{"type":"FeatureCollection"}', dataOriginDate: '2024-01-01', - } as Partial, + } as unknown as Partial, }) await waitFor(() => expect(result.current.isSuccess).toBe(true)) diff --git a/src/api/service/index.test.tsx b/src/api/service/index.test.tsx index 17b7ebb87..26e6d8264 100644 --- a/src/api/service/index.test.tsx +++ b/src/api/service/index.test.tsx @@ -31,7 +31,7 @@ describe('service', () => { }) it('info fetches the service info via the wired-through function', async () => { - const info = { version: '1.2.3' } as ServiceInfo + const info = { version: '1.2.3' } as unknown as ServiceInfo apiRequestMock.get.mockReturnValue({ json: () => Promise.resolve(info) }) const { result } = renderHookWithClient(() => service.info()) diff --git a/src/api/service/info.test.tsx b/src/api/service/info.test.tsx index 828fd8e43..99fc480ee 100644 --- a/src/api/service/info.test.tsx +++ b/src/api/service/info.test.tsx @@ -26,7 +26,7 @@ describe('serviceApi', () => { describe('info', () => { it('fetches the service info from the expected endpoint', async () => { - const info = { version: '1.2.3' } as ServiceInfo + const info = { version: '1.2.3' } as unknown as ServiceInfo apiRequestMock.get.mockReturnValue({ json: () => Promise.resolve(info) }) const { result } = renderHookWithClient(() => serviceApi.info()) @@ -38,7 +38,7 @@ describe('serviceApi', () => { }) it('uses the ["service", "info"] query key', async () => { - const info = { version: '1.2.3' } as ServiceInfo + const info = { version: '1.2.3' } as unknown as ServiceInfo apiRequestMock.get.mockReturnValue({ json: () => Promise.resolve(info) }) const { result, queryClient } = renderHookWithClient(() => serviceApi.info()) diff --git a/src/api/task/multiple.test.tsx b/src/api/task/multiple.test.tsx index 0697c6972..8ad43fb81 100644 --- a/src/api/task/multiple.test.tsx +++ b/src/api/task/multiple.test.tsx @@ -113,9 +113,7 @@ describe('taskMultiple', () => { it('passes undefined search params when params is falsy', async () => { apiRequestMock.get.mockReturnValue({ json: () => Promise.resolve({ markers: [] }) }) - const { result } = renderHookWithClient(() => - taskMultiple.getTaskMarkers(null as TaskMarkersParams) - ) + const { result } = renderHookWithClient(() => taskMultiple.getTaskMarkers(undefined)) await waitFor(() => expect(result.current.isSuccess).toBe(true)) diff --git a/src/components/Pages/ExploreChallengesPage/FilterBar/index.tsx b/src/components/Pages/ExploreChallengesPage/FilterBar/index.tsx index 975f38b49..927411d6f 100644 --- a/src/components/Pages/ExploreChallengesPage/FilterBar/index.tsx +++ b/src/components/Pages/ExploreChallengesPage/FilterBar/index.tsx @@ -46,7 +46,9 @@ export const FilterBar = () => { osm_id: locationOsmId ?? undefined, keywords: keywords && keywords !== '' ? keywords : undefined, difficulty: - difficulty !== undefined ? reverseDifficultyMap[difficulty as number] : undefined, + difficulty !== undefined + ? reverseDifficultyMap[difficulty as unknown as number] + : undefined, viewMode: viewMode !== 'grid-map' ? viewMode : undefined, }), hash: true, diff --git a/src/components/Pages/ManagementPages/ManageChallengeNew/ChallengeForm.test.tsx b/src/components/Pages/ManagementPages/ManageChallengeNew/ChallengeForm.test.tsx index a4453bff6..d33b2866f 100644 --- a/src/components/Pages/ManagementPages/ManageChallengeNew/ChallengeForm.test.tsx +++ b/src/components/Pages/ManagementPages/ManageChallengeNew/ChallengeForm.test.tsx @@ -37,7 +37,7 @@ vi.mock('@/contexts/ChallengeFormContext', () => ({ useChallengeFormContext: useChallengeFormContextMock, })) -const fakeUser = { osmProfile: { id: 1, displayName: 'TestUser' }, grants: [] } as User +const fakeUser = { osmProfile: { id: 1, displayName: 'TestUser' }, grants: [] } as unknown as User afterEach(() => cleanup()) diff --git a/src/components/Pages/ManagementPages/ManageProjectNew/ProjectForm.test.tsx b/src/components/Pages/ManagementPages/ManageProjectNew/ProjectForm.test.tsx index d8f834617..67f832b57 100644 --- a/src/components/Pages/ManagementPages/ManageProjectNew/ProjectForm.test.tsx +++ b/src/components/Pages/ManagementPages/ManageProjectNew/ProjectForm.test.tsx @@ -12,7 +12,7 @@ vi.mock('@/contexts/AuthContext', () => ({ useAuthContext: useAuthContextMock, })) -const regularUser = { grants: [] } as User +const regularUser = { grants: [] } as unknown as User afterEach(() => { cleanup() diff --git a/src/components/Pages/ManagementPages/ManageTaskEdit/TaskForm.test.tsx b/src/components/Pages/ManagementPages/ManageTaskEdit/TaskForm.test.tsx index 246a4109d..8402553e1 100644 --- a/src/components/Pages/ManagementPages/ManageTaskEdit/TaskForm.test.tsx +++ b/src/components/Pages/ManagementPages/ManageTaskEdit/TaskForm.test.tsx @@ -10,7 +10,7 @@ const baseTask = { geometries: { type: 'Point', coordinates: [0, 0] }, status: 0, errorTags: '', -} as TaskGetResponse +} as unknown as TaskGetResponse afterEach(() => cleanup()) diff --git a/src/components/Pages/TaskEditPage/TaskMap/useLassoEvents.test.tsx b/src/components/Pages/TaskEditPage/TaskMap/useLassoEvents.test.tsx index ff3831e9a..13f67fdff 100644 --- a/src/components/Pages/TaskEditPage/TaskMap/useLassoEvents.test.tsx +++ b/src/components/Pages/TaskEditPage/TaskMap/useLassoEvents.test.tsx @@ -88,6 +88,7 @@ const makeCanvasMap = () => { } type FakeMap = ReturnType +type FakeMapRefCurrent = { getMap: () => FakeMap } interface Overrides { drawingMode?: LassoMode @@ -102,7 +103,7 @@ interface Overrides { activeBundle?: TaskBundle | null userId?: number | null markers?: TaskMarker[] - mapRefCurrent?: FakeMap | null + mapRefCurrent?: FakeMapRefCurrent | null } const setContext = ({ @@ -156,7 +157,7 @@ describe('useLassoEvents', () => { setContext({ drawingMode: null, setIsDrawing, - mapRefCurrent: mapRefWithFakeMap(fakeMap) as FakeMap, + mapRefCurrent: mapRefWithFakeMap(fakeMap), }) renderHook(() => useLassoEvents()) @@ -174,7 +175,7 @@ describe('useLassoEvents', () => { drawingMode: 'select', setIsDrawing, setLassoPolygon, - mapRefCurrent: mapRefWithFakeMap(fakeMap) as FakeMap, + mapRefCurrent: mapRefWithFakeMap(fakeMap), }) renderHook(() => useLassoEvents()) @@ -192,7 +193,7 @@ describe('useLassoEvents', () => { setContext({ drawingMode: 'select', setIsDrawing, - mapRefCurrent: mapRefWithFakeMap(fakeMap) as FakeMap, + mapRefCurrent: mapRefWithFakeMap(fakeMap), }) renderHook(() => useLassoEvents()) @@ -208,7 +209,7 @@ describe('useLassoEvents', () => { setContext({ drawingMode: 'select', setLassoPolygon, - mapRefCurrent: mapRefWithFakeMap(fakeMap) as FakeMap, + mapRefCurrent: mapRefWithFakeMap(fakeMap), }) renderHook(() => useLassoEvents()) @@ -255,7 +256,7 @@ describe('useLassoEvents', () => { userId: 1, activeBundle: { bundleId: 9, taskIds: [6], name: 'b' }, markers, - mapRefCurrent: mapRefWithFakeMap(fakeMap) as FakeMap, + mapRefCurrent: mapRefWithFakeMap(fakeMap), }) renderHook(() => useLassoEvents()) @@ -287,7 +288,7 @@ describe('useLassoEvents', () => { setSelectedTaskIds, taskId: 999, markers, - mapRefCurrent: mapRefWithFakeMap(fakeMap) as FakeMap, + mapRefCurrent: mapRefWithFakeMap(fakeMap), }) renderHook(() => useLassoEvents()) @@ -316,7 +317,7 @@ describe('useLassoEvents', () => { setSelectedTaskIds, taskId: 999, markers, - mapRefCurrent: mapRefWithFakeMap(fakeMap) as FakeMap, + mapRefCurrent: mapRefWithFakeMap(fakeMap), }) renderHook(() => useLassoEvents()) @@ -340,7 +341,7 @@ describe('useLassoEvents', () => { drawingMode: 'select', setSelectedTaskIds, markers: [makeMarker(1, 5, 5)], - mapRefCurrent: mapRefWithFakeMap(fakeMap) as FakeMap, + mapRefCurrent: mapRefWithFakeMap(fakeMap), }) renderHook(() => useLassoEvents()) @@ -357,7 +358,7 @@ describe('useLassoEvents', () => { setContext({ drawingMode: 'select', cancelDrawing, - mapRefCurrent: mapRefWithFakeMap(fakeMap) as FakeMap, + mapRefCurrent: mapRefWithFakeMap(fakeMap), }) renderHook(() => useLassoEvents()) @@ -373,7 +374,7 @@ describe('useLassoEvents', () => { setContext({ drawingMode: null, cancelDrawing, - mapRefCurrent: mapRefWithFakeMap(fakeMap) as FakeMap, + mapRefCurrent: mapRefWithFakeMap(fakeMap), }) renderHook(() => useLassoEvents()) @@ -389,7 +390,7 @@ describe('useLassoEvents', () => { setContext({ drawingMode: 'select', setIsDrawing, - mapRefCurrent: mapRefWithFakeMap(fakeMap) as FakeMap, + mapRefCurrent: mapRefWithFakeMap(fakeMap), }) const { unmount } = renderHook(() => useLassoEvents()) diff --git a/src/plugins/DynamicPluginLoader.ts b/src/plugins/DynamicPluginLoader.ts index 91cc9bb19..158b0180b 100644 --- a/src/plugins/DynamicPluginLoader.ts +++ b/src/plugins/DynamicPluginLoader.ts @@ -104,7 +104,7 @@ export const loadPluginViaScript = ( script.onload = () => { try { - const windowWithPlugin = window as Window & Record + const windowWithPlugin = window as unknown as Window & Record const pluginModule = windowWithPlugin[globalName] as | { default?: Plugin; plugin?: Plugin } | Plugin From 3b4abe1bb75d1545008a1c8d506fb45f50f63b32 Mon Sep 17 00:00:00 2001 From: Collin Beczak Date: Thu, 16 Jul 2026 20:22:34 -0500 Subject: [PATCH 6/6] remove dead code --- src/components/Map/TaskMarkers/const.ts | 11 -- src/components/Map/TaskMarkers/utils.ts | 14 -- src/components/Map/mapUtils.ts | 73 +------ .../BrowsedChallengeSearchContext.tsx | 12 +- .../PrioritizationContent.tsx | 8 +- .../PrioritizationContext.tsx | 17 -- .../TaskPreviewContext.tsx | 4 +- .../evaluation/evaluatePriority.ts | 124 ------------ .../evaluation/pointInPolygon.ts | 17 -- .../evaluation/ruleAnalysis.ts | 5 - .../TaskPrioritizationPage/index.tsx | 3 - .../contexts/ProfilePageContext.tsx | 8 - .../TaskEditPage/contexts/OSMDataContext.tsx | 10 +- .../TaskEditPage/contexts/ProjectContext.tsx | 12 +- .../shared/CommentComposer/index.ts | 3 - src/components/shared/CommentList/index.ts | 3 - src/components/shared/ShareLink/ShareLink.tsx | 42 ---- src/components/shared/ShareLink/index.ts | 1 - .../shared/TaskPropertyQueryBuilder/index.tsx | 8 +- .../shared/TopChallengesList/index.ts | 1 - src/components/ui/BackLink.tsx | 23 --- src/i18n/index.ts | 2 - src/lib/challengeTaskTableSearch.ts | 14 -- src/lib/logger.ts | 1 - .../RapidEditorPlugin/RapidEditorPlugin.tsx | 39 ---- .../RapidEditorPlugin/RapidEditorView.tsx | 180 ------------------ src/plugins/RapidEditorPlugin/editorUtils.ts | 50 ----- src/plugins/index.ts | 9 - src/types/Challenge.ts | 20 -- src/types/GlobalSearch.ts | 98 ---------- src/types/Notification.ts | 71 ------- src/types/Plugin.ts | 10 - src/types/Project.ts | 6 +- src/types/Task.ts | 7 - src/types/Team.ts | 2 - src/types/rapidEditor.ts | 23 --- 36 files changed, 11 insertions(+), 920 deletions(-) delete mode 100644 src/components/Pages/ManagementPages/TaskPrioritizationPage/evaluation/evaluatePriority.ts delete mode 100644 src/components/Pages/ManagementPages/TaskPrioritizationPage/evaluation/pointInPolygon.ts delete mode 100644 src/components/shared/ShareLink/ShareLink.tsx delete mode 100644 src/components/shared/ShareLink/index.ts delete mode 100644 src/components/ui/BackLink.tsx delete mode 100644 src/plugins/RapidEditorPlugin/RapidEditorPlugin.tsx delete mode 100644 src/plugins/RapidEditorPlugin/RapidEditorView.tsx delete mode 100644 src/plugins/index.ts delete mode 100644 src/types/rapidEditor.ts diff --git a/src/components/Map/TaskMarkers/const.ts b/src/components/Map/TaskMarkers/const.ts index e9dc9aa62..9aee1fb82 100644 --- a/src/components/Map/TaskMarkers/const.ts +++ b/src/components/Map/TaskMarkers/const.ts @@ -36,17 +36,6 @@ export const CLUSTER_CONFIG = { // (TileAggregateRepository.scala). export const CLUSTER_RADIUS_PX = 64 -export const OVERLAP_CONFIG = { - threshold: 0.000001, // Degrees - roughly 0.1 meters, for truly overlapping markers only - maxOverlapRadius: 15, // Maximum radius for overlap visualization - minOverlapRadius: 8, // Minimum radius for overlap visualization - overlapColors: { - light: '#ff6b6b', - medium: '#ff5252', - heavy: '#d32f2f', - }, -} - const POINTS_LAYER_ID = 'task-unclustered-point' const POINTS_CREATED_LAYER_ID = 'task-unclustered-point-created' diff --git a/src/components/Map/TaskMarkers/utils.ts b/src/components/Map/TaskMarkers/utils.ts index abe8441da..86c07a862 100644 --- a/src/components/Map/TaskMarkers/utils.ts +++ b/src/components/Map/TaskMarkers/utils.ts @@ -297,17 +297,3 @@ export const isValidLocation = ( Number.isFinite(location.lat) ) } - -export const isValidOverlapCenter = (center: unknown): center is [number, number] => { - return ( - center != null && - Array.isArray(center) && - center.length === 2 && - typeof center[0] === 'number' && - typeof center[1] === 'number' && - !Number.isNaN(center[0]) && - !Number.isNaN(center[1]) && - Number.isFinite(center[0]) && - Number.isFinite(center[1]) - ) -} diff --git a/src/components/Map/mapUtils.ts b/src/components/Map/mapUtils.ts index 8ec24bcfd..e6252b9bf 100644 --- a/src/components/Map/mapUtils.ts +++ b/src/components/Map/mapUtils.ts @@ -5,10 +5,10 @@ import type { Bbox2D } from '@/types/Map' * Valid geographic coordinate limits * Using slightly inside the theoretical limits to avoid strict validation errors */ -export const MAX_LON = 180 -export const MIN_LON = -180 -export const MAX_LAT = 85 -export const MIN_LAT = -85 +const MAX_LON = 180 +const MIN_LON = -180 +const MAX_LAT = 85 +const MIN_LAT = -85 /** * Default world bounds string @@ -136,21 +136,6 @@ export const boundsAreEqual = ( ) } -/** - * Fly to a specific location - */ -export const flyToLocation = ( - map: maplibregl.Map, - center: [number, number], - zoom: number = 12, - _duration: number = 0 -) => { - map.jumpTo({ - center, - zoom, - }) -} - /** * Reset map to default view */ @@ -164,53 +149,3 @@ export const resetMapView = ( zoom, }) } - -/** - * Check if a layer exists on the map - */ -export const layerExists = (map: maplibregl.Map, layerId: string): boolean => { - return !!map.getLayer(layerId) -} - -/** - * Check if a source exists on the map - */ -export const sourceExists = (map: maplibregl.Map, sourceId: string): boolean => { - return !!map.getSource(sourceId) -} - -/** - * Safely remove a layer from the map - */ -export const removeLayer = (map: maplibregl.Map, layerId: string): void => { - if (layerExists(map, layerId)) { - map.removeLayer(layerId) - } -} - -/** - * Safely remove a source from the map - */ -export const removeSource = (map: maplibregl.Map, sourceId: string): void => { - if (sourceExists(map, sourceId)) { - map.removeSource(sourceId) - } -} - -/** - * Remove multiple layers from the map - */ -export const removeLayers = (map: maplibregl.Map, layerIds: string[]): void => { - layerIds.forEach((layerId) => { - removeLayer(map, layerId) - }) -} - -/** - * Remove multiple sources from the map - */ -export const removeSources = (map: maplibregl.Map, sourceIds: string[]): void => { - sourceIds.forEach((sourceId) => { - removeSource(map, sourceId) - }) -} diff --git a/src/components/Pages/BrowsedChallengePage/contexts/BrowsedChallengeSearchContext.tsx b/src/components/Pages/BrowsedChallengePage/contexts/BrowsedChallengeSearchContext.tsx index 92a03cc2b..fb596681c 100644 --- a/src/components/Pages/BrowsedChallengePage/contexts/BrowsedChallengeSearchContext.tsx +++ b/src/components/Pages/BrowsedChallengePage/contexts/BrowsedChallengeSearchContext.tsx @@ -1,5 +1,5 @@ import type { ReactNode } from 'react' -import { createContext, useContext, useMemo, useState } from 'react' +import { createContext, useMemo, useState } from 'react' import type { TaskMarkersParams } from '@/types/Task' type DefinedTaskMarkersParams = NonNullable @@ -42,13 +42,3 @@ export const BrowsedChallengeSearchContextProvider = ({ children }: { children: ) } - -export const useBrowsedChallengeSearchContext = () => { - const context = useContext(BrowsedChallengeSearchContext) - if (context === undefined) { - throw new Error( - 'useBrowsedChallengeSearchContext must be used within an BrowsedChallengeSearchContextProvider' - ) - } - return context -} diff --git a/src/components/Pages/ManagementPages/TaskPrioritizationPage/PrioritizationContent.tsx b/src/components/Pages/ManagementPages/TaskPrioritizationPage/PrioritizationContent.tsx index c161448e7..77c691290 100644 --- a/src/components/Pages/ManagementPages/TaskPrioritizationPage/PrioritizationContent.tsx +++ b/src/components/Pages/ManagementPages/TaskPrioritizationPage/PrioritizationContent.tsx @@ -1,5 +1,5 @@ import { useNavigate } from '@tanstack/react-router' -import { createContext, useContext, useRef, useState } from 'react' +import { createContext, useRef, useState } from 'react' import { createPortal } from 'react-dom' import type { MapRef } from 'react-map-gl/maplibre' import { toast } from 'sonner' @@ -34,12 +34,6 @@ interface PreviewMapBridgeValue { const PreviewMapBridgeContext = createContext(null) -export const usePreviewMapBridge = () => { - const ctx = useContext(PreviewMapBridgeContext) - if (!ctx) throw new Error('usePreviewMapBridge must be used within PrioritizationContent') - return ctx -} - export const PrioritizationContent = ({ challengeId, challengeName }: Props) => { const { draft, isDirty, reset, markSaved, setTierBounds } = usePrioritizationContext() const mutation = api.challenge.useUpdatePriorities() diff --git a/src/components/Pages/ManagementPages/TaskPrioritizationPage/PrioritizationContext.tsx b/src/components/Pages/ManagementPages/TaskPrioritizationPage/PrioritizationContext.tsx index 18eccefa4..47438d5d3 100644 --- a/src/components/Pages/ManagementPages/TaskPrioritizationPage/PrioritizationContext.tsx +++ b/src/components/Pages/ManagementPages/TaskPrioritizationPage/PrioritizationContext.tsx @@ -12,12 +12,6 @@ export const TIER_TO_PRIORITY: Record = { low: TaskPriority.LOW, } -export const PRIORITY_TO_TIER: Record = { - 0: 'high', - 1: 'medium', - 2: 'low', -} - export interface TierDraft { rules: BinaryNode | null bounds: GeoJSON.FeatureCollection | null @@ -30,17 +24,6 @@ export interface PrioritizationDraft { low: TierDraft } -export const emptyTier = (): TierDraft => ({ rules: null, bounds: null }) - -export const makeInitialDraft = ( - defaultPriority: TaskPriorityValue = TaskPriority.MEDIUM -): PrioritizationDraft => ({ - defaultPriority, - high: emptyTier(), - medium: emptyTier(), - low: emptyTier(), -}) - /** * Parse a raw server-stored bounds string into a FeatureCollection. * Accepts either a FeatureCollection or a GeoJSON feature array (MR3 format). diff --git a/src/components/Pages/ManagementPages/TaskPrioritizationPage/TaskPreviewContext.tsx b/src/components/Pages/ManagementPages/TaskPrioritizationPage/TaskPreviewContext.tsx index 220191c08..8a6670129 100644 --- a/src/components/Pages/ManagementPages/TaskPrioritizationPage/TaskPreviewContext.tsx +++ b/src/components/Pages/ManagementPages/TaskPrioritizationPage/TaskPreviewContext.tsx @@ -1,7 +1,7 @@ import { createContext, type ReactNode, useContext, useEffect, useMemo, useState } from 'react' import { api } from '@/api' import { binaryToBackendJson } from '@/components/shared/TaskPropertyQueryBuilder/backendRuleShape' -import { TaskPriority, type TaskPriorityValue } from '@/types/Priority' +import type { TaskPriorityValue } from '@/types/Priority' import type { TaskMarker } from '@/types/Task' import { analyzeWarnings, type PrioritizationWarnings } from './evaluation/ruleAnalysis' import { type PrioritizationDraft, usePrioritizationContext } from './PrioritizationContext' @@ -130,5 +130,3 @@ export const useTaskPreview = () => { if (!ctx) throw new Error('useTaskPreview must be used inside TaskPreviewProvider') return ctx } - -export { TaskPriority } diff --git a/src/components/Pages/ManagementPages/TaskPrioritizationPage/evaluation/evaluatePriority.ts b/src/components/Pages/ManagementPages/TaskPrioritizationPage/evaluation/evaluatePriority.ts deleted file mode 100644 index 4be8f552a..000000000 --- a/src/components/Pages/ManagementPages/TaskPrioritizationPage/evaluation/evaluatePriority.ts +++ /dev/null @@ -1,124 +0,0 @@ -import type { - BinaryGroup, - BinaryLeaf, - BinaryNode, -} from '@/components/shared/TaskPropertyQueryBuilder/propertyRuleTypes' -import { TaskPriority, type TaskPriorityValue } from '@/types/Priority' -import { pointInFeatureCollection } from './pointInPolygon' - -const isGroup = (node: BinaryNode): node is BinaryGroup => - (node as BinaryGroup).valueType === 'compound rule' - -export interface TaskPreviewInput { - id: number - lng: number - lat: number - /** Current server-side priority, used to compute diff. */ - currentPriority: number | null | undefined - /** Optional per-task properties; present when the caller has them (otherwise property rules abstain). */ - properties?: Record | null -} - -export interface TierConfig { - priority: TaskPriorityValue - rules: BinaryNode | null - bounds: GeoJSON.FeatureCollection | null -} - -export interface EvaluationConfig { - defaultPriority: TaskPriorityValue - tiers: TierConfig[] -} - -const coerceNumber = (value: unknown): number | null => { - if (typeof value === 'number' && Number.isFinite(value)) return value - if (typeof value === 'string' && value.trim() !== '') { - const parsed = Number(value) - if (Number.isFinite(parsed)) return parsed - } - return null -} - -const evaluateLeaf = (leaf: BinaryLeaf, task: TaskPreviewInput): boolean | 'abstain' => { - const properties = task.properties - // If caller passed no properties, property-based rules abstain (unknown), - // which is treated as a non-match so the preview does not over-promise. - if (!properties) return 'abstain' - - const actual = properties[leaf.key] - const operator = leaf.operator - if (operator === 'exists') return actual !== undefined && actual !== null && actual !== '' - if (operator === 'missing') return actual === undefined || actual === null || actual === '' - - if (actual === undefined || actual === null) return false - - if (operator === 'equals') return String(actual) === leaf.value - if (operator === 'notEqual') return String(actual) !== leaf.value - if (operator === 'contains') return String(actual).includes(leaf.value) - if (operator === 'greaterThan' || operator === 'lessThan') { - const a = coerceNumber(actual) - const b = coerceNumber(leaf.value) - if (a === null || b === null) return false - return operator === 'greaterThan' ? a > b : a < b - } - return false -} - -/** - * Evaluate a rule tree against a task. Returns boolean for a definitive answer, - * or 'abstain' when the rules depend on data we don't have (e.g. tag properties). - * Callers decide how to treat abstain — the preview treats it as non-match for - * property rules, and honest "server will apply" messaging surfaces it. - */ -export const evaluateRuleTree = ( - node: BinaryNode | null, - task: TaskPreviewInput -): boolean | 'abstain' => { - if (!node) return false - if (!isGroup(node)) return evaluateLeaf(node, task) - const left = evaluateRuleTree(node.left, task) - const right = evaluateRuleTree(node.right, task) - if (node.condition === 'and') { - if (left === false || right === false) return false - if (left === 'abstain' || right === 'abstain') return 'abstain' - return true - } - // or - if (left === true || right === true) return true - if (left === 'abstain' || right === 'abstain') return 'abstain' - return false -} - -const tierMatches = (tier: TierConfig, task: TaskPreviewInput): boolean => { - const hasBounds = !!tier.bounds && (tier.bounds.features?.length ?? 0) > 0 - const hasRules = !!tier.rules - if (!hasBounds && !hasRules) return false - - // Match backend semantics exactly: `Task.getTaskPriority` returns a tier's - // priority when the task is within that tier's bounds OR when its geometry - // properties satisfy the tier's rules. Bounds stand on their own — the - // preview shouldn't gate a bounds match behind a rule match. - if (hasBounds && pointInFeatureCollection(task.lng, task.lat, tier.bounds) === true) { - return true - } - if (hasRules && evaluateRuleTree(tier.rules, task) === true) { - return true - } - return false -} - -/** - * Resolve the effective priority for a task. Walks tiers top-down (HIGH → MEDIUM → LOW) - * and returns the first matching tier's priority; otherwise returns the default. - */ -export const evaluatePriority = ( - task: TaskPreviewInput, - config: EvaluationConfig -): TaskPriorityValue => { - for (const tier of config.tiers) { - if (tierMatches(tier, task)) return tier.priority - } - return config.defaultPriority -} - -export { TaskPriority } diff --git a/src/components/Pages/ManagementPages/TaskPrioritizationPage/evaluation/pointInPolygon.ts b/src/components/Pages/ManagementPages/TaskPrioritizationPage/evaluation/pointInPolygon.ts deleted file mode 100644 index aa04ce45f..000000000 --- a/src/components/Pages/ManagementPages/TaskPrioritizationPage/evaluation/pointInPolygon.ts +++ /dev/null @@ -1,17 +0,0 @@ -import booleanPointInPolygon from '@turf/boolean-point-in-polygon' - -export const pointInFeatureCollection = ( - lng: number, - lat: number, - fc: GeoJSON.FeatureCollection | null | undefined -): boolean => { - if (!fc || !fc.features) return false - for (const feature of fc.features) { - const geom = feature.geometry - if (!geom) continue - if (geom.type === 'Polygon' || geom.type === 'MultiPolygon') { - if (booleanPointInPolygon([lng, lat], geom)) return true - } - } - return false -} diff --git a/src/components/Pages/ManagementPages/TaskPrioritizationPage/evaluation/ruleAnalysis.ts b/src/components/Pages/ManagementPages/TaskPrioritizationPage/evaluation/ruleAnalysis.ts index f59314aee..c6376b319 100644 --- a/src/components/Pages/ManagementPages/TaskPrioritizationPage/evaluation/ruleAnalysis.ts +++ b/src/components/Pages/ManagementPages/TaskPrioritizationPage/evaluation/ruleAnalysis.ts @@ -1,10 +1,5 @@ import type { TaskPriorityValue } from '@/types/Priority' -export interface TierEvaluationTotals { - matched: number - changed: number -} - /** * Surface-level warnings computed once per evaluation pass. The UI renders * these inline on each tier header and on the summary card. diff --git a/src/components/Pages/ManagementPages/TaskPrioritizationPage/index.tsx b/src/components/Pages/ManagementPages/TaskPrioritizationPage/index.tsx index 98703fce7..5a721ba20 100644 --- a/src/components/Pages/ManagementPages/TaskPrioritizationPage/index.tsx +++ b/src/components/Pages/ManagementPages/TaskPrioritizationPage/index.tsx @@ -6,7 +6,6 @@ import { Spinner } from '@/components/ui/Spinner' import { TaskPriority, type TaskPriorityValue } from '@/types/Priority' import { PrioritizationContent } from './PrioritizationContent' import { - makeInitialDraft, type PrioritizationDraft, PrioritizationProvider, parseBoundsString, @@ -92,5 +91,3 @@ export const TaskPrioritizationPage = ({ challengeId }: Props) => { ) } - -export { makeInitialDraft } diff --git a/src/components/Pages/ProfilePage/contexts/ProfilePageContext.tsx b/src/components/Pages/ProfilePage/contexts/ProfilePageContext.tsx index 89012abef..06c80cce1 100644 --- a/src/components/Pages/ProfilePage/contexts/ProfilePageContext.tsx +++ b/src/components/Pages/ProfilePage/contexts/ProfilePageContext.tsx @@ -1,20 +1,13 @@ import { createContext, type ReactNode, useContext, useMemo, useState } from 'react' -export interface DateRange { - startDate?: string - endDate?: string -} - export interface TimeRangeState { monthDuration: number - customRange?: DateRange } interface ProfilePageContextValue { userId: number timeRange: TimeRangeState setMonthDuration: (monthDuration: number) => void - setCustomRange: (range: DateRange) => void } const ProfilePageContext = createContext(null) @@ -32,7 +25,6 @@ export const ProfilePageProvider = ({ userId, children }: ProviderProps) => { userId, timeRange, setMonthDuration: (monthDuration) => setTimeRange({ monthDuration }), - setCustomRange: (customRange) => setTimeRange({ monthDuration: -2, customRange }), }), [userId, timeRange] ) diff --git a/src/components/Pages/TaskEditPage/contexts/OSMDataContext.tsx b/src/components/Pages/TaskEditPage/contexts/OSMDataContext.tsx index 7d574b947..b2a55f095 100644 --- a/src/components/Pages/TaskEditPage/contexts/OSMDataContext.tsx +++ b/src/components/Pages/TaskEditPage/contexts/OSMDataContext.tsx @@ -1,5 +1,5 @@ import type { Dispatch, ReactNode, SetStateAction } from 'react' -import { createContext, useCallback, useContext, useMemo, useState } from 'react' +import { createContext, useCallback, useMemo, useState } from 'react' import { toast } from 'sonner' import { api } from '@/api' import { logger } from '@/lib/logger' @@ -153,11 +153,3 @@ export const OSMDataProvider = ({ children }: { children: ReactNode }) => { return {children} } - -export const useOSMDataContext = () => { - const context = useContext(OSMDataContext) - if (context === undefined) { - throw new Error('useOSMDataContext must be used within an OSMDataProvider') - } - return context -} diff --git a/src/components/Pages/TaskEditPage/contexts/ProjectContext.tsx b/src/components/Pages/TaskEditPage/contexts/ProjectContext.tsx index e45aa3a7a..39e74d60a 100644 --- a/src/components/Pages/TaskEditPage/contexts/ProjectContext.tsx +++ b/src/components/Pages/TaskEditPage/contexts/ProjectContext.tsx @@ -1,4 +1,4 @@ -import { createContext, type ReactNode, useContext, useMemo } from 'react' +import { createContext, type ReactNode, useMemo } from 'react' import { api } from '@/api' import type { Project } from '@/types/Project' import { useChallengeContext } from './ChallengeContext' @@ -27,13 +27,3 @@ export const ProjectProvider = ({ children }: { children: ReactNode }) => { return {children} } - -export const useProjectContext = () => { - const context = useContext(ProjectContext) - - if (context === undefined) { - throw new Error('useProject must be used within a ProjectProvider') - } - - return context -} diff --git a/src/components/shared/CommentComposer/index.ts b/src/components/shared/CommentComposer/index.ts index e3dc0f204..077c984d6 100644 --- a/src/components/shared/CommentComposer/index.ts +++ b/src/components/shared/CommentComposer/index.ts @@ -1,4 +1 @@ export { CommentComposer } from './CommentComposer' -export { CommentMentionInput } from './CommentMentionInput' -export type { MentionMatch } from './mentionUtils' -export { findMention, insertMention } from './mentionUtils' diff --git a/src/components/shared/CommentList/index.ts b/src/components/shared/CommentList/index.ts index 6d21687d2..d28811145 100644 --- a/src/components/shared/CommentList/index.ts +++ b/src/components/shared/CommentList/index.ts @@ -1,4 +1 @@ -export { CommentItem } from './CommentItem' export { CommentList } from './CommentList' -export { CommentMarkdown } from './CommentMarkdown' -export { sortComments } from './commentSorting' diff --git a/src/components/shared/ShareLink/ShareLink.tsx b/src/components/shared/ShareLink/ShareLink.tsx deleted file mode 100644 index 3b96eb8bf..000000000 --- a/src/components/shared/ShareLink/ShareLink.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import { Share2 } from 'lucide-react' -import { Button } from '@/components/ui/Button' -import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/Popover' -import { SharePopoverContent } from './SharePopoverContent' - -interface Props { - path: string - title?: string - description?: string - variant?: 'icon' | 'button' - align?: 'start' | 'center' | 'end' -} - -const buildAbsoluteUrl = (path: string): string => { - if (typeof window === 'undefined') return path - const base = window.location.origin - return path.startsWith('http') ? path : `${base}${path}` -} - -export const ShareLink = ({ path, title, description, variant = 'icon', align = 'end' }: Props) => { - const url = buildAbsoluteUrl(path) - - const trigger = - variant === 'icon' ? ( - - ) : ( - - ) - - return ( - - {trigger} - - - - - ) -} diff --git a/src/components/shared/ShareLink/index.ts b/src/components/shared/ShareLink/index.ts deleted file mode 100644 index 36f13c060..000000000 --- a/src/components/shared/ShareLink/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { ShareLink } from './ShareLink' diff --git a/src/components/shared/TaskPropertyQueryBuilder/index.tsx b/src/components/shared/TaskPropertyQueryBuilder/index.tsx index 2b8cdafb5..7f8151c2b 100644 --- a/src/components/shared/TaskPropertyQueryBuilder/index.tsx +++ b/src/components/shared/TaskPropertyQueryBuilder/index.tsx @@ -60,10 +60,4 @@ export const TaskPropertyQueryBuilder = ({ value, onChange }: Props) => { ) } -export { - binaryToFlat, - describeRule, - flatToBinary, - validatePropertyRules, -} from './propertyRuleConversion' -export type { BinaryNode, PropertyRuleGroup } from './propertyRuleTypes' +export type { PropertyRuleGroup } from './propertyRuleTypes' diff --git a/src/components/shared/TopChallengesList/index.ts b/src/components/shared/TopChallengesList/index.ts index 0c700ffb1..9d6bdcb28 100644 --- a/src/components/shared/TopChallengesList/index.ts +++ b/src/components/shared/TopChallengesList/index.ts @@ -1,2 +1 @@ -export { TopChallengeRow } from './TopChallengeRow' export { TopChallengesList } from './TopChallengesList' diff --git a/src/components/ui/BackLink.tsx b/src/components/ui/BackLink.tsx deleted file mode 100644 index cacbd3849..000000000 --- a/src/components/ui/BackLink.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import { Link } from '@tanstack/react-router' -import { ArrowLeft } from 'lucide-react' - -interface BackLinkProps { - to: string - params?: Record - search?: Record - children: React.ReactNode -} - -export const BackLink = ({ to, params, search, children }: BackLinkProps) => { - return ( - - - {children} - - ) -} diff --git a/src/i18n/index.ts b/src/i18n/index.ts index 0024d7116..ce5c7ff5c 100644 --- a/src/i18n/index.ts +++ b/src/i18n/index.ts @@ -1,3 +1 @@ export { IntlProvider, useIntl } from './IntlContext' -export type { Locale } from './locales' -export { defaultLocale, isSupportedLocale, resolveInitialLocale, supportedLocales } from './locales' diff --git a/src/lib/challengeTaskTableSearch.ts b/src/lib/challengeTaskTableSearch.ts index 5ed6b1dec..967ff1b3a 100644 --- a/src/lib/challengeTaskTableSearch.ts +++ b/src/lib/challengeTaskTableSearch.ts @@ -4,10 +4,6 @@ export const DEFAULT_TASK_STATUS_FILTER = [0, 1, 2, 3, 4, 5, 6, 9] as const export const DEFAULT_PRIORITY_FILTER = [0, 1, 2] as const -export const DEFAULT_REVIEW_STATUS_FILTER = [0, 1, 2, 3, 4, 5, 6, 7, -1] as const - -export const DEFAULT_META_REVIEW_STATUS_FILTER = [0, 1, 2, 3, 5, 6, 7, -2, -1] as const - /** Mirrors maproulette3 generateSearchParametersString metaReviewStatus handling */ export const metaReviewStatusesForApi = ( reviewStatuses: number[], @@ -19,13 +15,3 @@ export const metaReviewStatusesForApi = ( } return meta } - -export const CHALLENGE_TASK_SORT_FIELDS = [ - { value: 'name', label: 'Name' }, - { value: 'id', label: 'ID' }, - { value: 'status', label: 'Status' }, - { value: 'priority', label: 'Priority' }, - { value: 'mappedOn', label: 'Mapped on' }, -] as const - -export type ChallengeTaskSortField = (typeof CHALLENGE_TASK_SORT_FIELDS)[number]['value'] diff --git a/src/lib/logger.ts b/src/lib/logger.ts index 58490f131..029b13f04 100644 --- a/src/lib/logger.ts +++ b/src/lib/logger.ts @@ -67,6 +67,5 @@ export const logger: Logger = { } // Create pre-configured scoped loggers for common use cases -export const apiLogger = createScope('API') export const wsLogger = createScope('WebSocket') export const pluginLogger = createScope('Plugin') diff --git a/src/plugins/RapidEditorPlugin/RapidEditorPlugin.tsx b/src/plugins/RapidEditorPlugin/RapidEditorPlugin.tsx deleted file mode 100644 index 84a883cbc..000000000 --- a/src/plugins/RapidEditorPlugin/RapidEditorPlugin.tsx +++ /dev/null @@ -1,39 +0,0 @@ -/** - * Rapid Editor Plugin for MapRoulette - * Integrates the Rapid editor into the task map for direct OSM editing - */ - -import { Pencil } from 'lucide-react' -import type { Plugin, TaskMapEditor } from '@/types/Plugin' -import { RapidEditorView } from './RapidEditorView' - -/** - * Rapid Editor Plugin Definition - * This plugin provides the Rapid editor as a task map overlay - */ -export const RapidEditorPlugin: Plugin = { - metadata: { - id: 'rapid-editor-plugin', - name: 'Rapid Editor', - description: - 'Integrates the Rapid editor into MapRoulette task map for direct OpenStreetMap editing', - version: '1.0.0', - author: 'MapRoulette Team', - }, - - initialize: async () => {}, - - cleanup: async () => {}, - - getTaskMapEditors: (): TaskMapEditor[] => { - return [ - { - id: 'rapid', - label: 'Edit in Rapid', - icon: , - component: RapidEditorView, - order: 10, - }, - ] - }, -} diff --git a/src/plugins/RapidEditorPlugin/RapidEditorView.tsx b/src/plugins/RapidEditorPlugin/RapidEditorView.tsx deleted file mode 100644 index 7a13fb12e..000000000 --- a/src/plugins/RapidEditorPlugin/RapidEditorView.tsx +++ /dev/null @@ -1,180 +0,0 @@ -/** - * Rapid Editor View Component - * Can be embedded in the TaskMap to provide direct OSM editing - */ - -import { useEffect, useRef, useState } from 'react' -import { buildChangesetComment } from '@/lib/changesetComment' -import { logger } from '@/lib/logger' -import type { RapidIframeWindow } from '@/types/rapidEditor' -import { useChallengeContext } from '../../components/Pages/TaskEditPage/contexts/ChallengeContext' -import { useTaskContext } from '../../components/Pages/TaskEditPage/contexts/TaskContext' -import { useTaskMapContext } from '../../components/Pages/TaskEditPage/contexts/TaskMapContext' -import { constructRapidURI, getOSMToken } from './editorUtils' - -interface RapidEditorViewProps { - onClose?: () => void -} - -export const RapidEditorView = ({ onClose }: RapidEditorViewProps) => { - const { task } = useTaskContext() - const { challenge } = useChallengeContext() - const { map } = useTaskMapContext() - const [isLoading, setIsLoading] = useState(true) - const [error, setError] = useState(null) - const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false) - const iframeRef = useRef(null) - - const mapBounds = map.current - ? (() => { - const maplibreMap = map.current.getMap() - const center = maplibreMap.getCenter() - return { - lat: center.lat, - lng: center.lng, - zoom: maplibreMap.getZoom(), - } - })() - : undefined - - const initialHash = constructRapidURI(task, mapBounds, { - comment: buildChangesetComment(challenge, task.id), - }) - - const token = getOSMToken() - const osmApiServer = window.env.VITE_OSM_API_SERVER || 'https://api.openstreetmap.org' - - let initialUrl = `/rapid-editor.html${initialHash}` - - if (osmApiServer === 'https://api.openstreetmap.org' && token) { - initialUrl += `&token=${token}` - } - - const handleResetHash = () => { - if (iframeRef.current?.contentWindow) { - iframeRef.current.contentWindow.location.hash = initialHash - } - } - - const handleIframeLoad = async (event: React.SyntheticEvent) => { - const iframe = event.target as HTMLIFrameElement - - try { - const win = iframe.contentWindow as RapidIframeWindow | null - const context = await win?.setupRapid?.() - - const editor = context?.systems?.editor - if (editor) { - editor.on('stablechange', () => { - setHasUnsavedChanges(editor.hasChanges()) - }) - } - - if (iframe.contentWindow) { - iframe.contentWindow.location.hash = initialHash - } - - setIsLoading(false) - } catch (err) { - logger.error('Failed to initialize Rapid editor', { error: err }) - setError(err as Error) - setIsLoading(false) - } - } - - useEffect(() => { - const handleBeforeUnload = (e: BeforeUnloadEvent) => { - if (hasUnsavedChanges) { - e.preventDefault() - e.returnValue = '' - } - } - - window.addEventListener('beforeunload', handleBeforeUnload) - return () => { - window.removeEventListener('beforeunload', handleBeforeUnload) - } - }, [hasUnsavedChanges]) - - const handleClose = () => { - if (hasUnsavedChanges) { - const confirmed = window.confirm( - 'You have unsaved changes in the Rapid editor. Are you sure you want to close it?' - ) - if (!confirmed) return - } - onClose?.() - } - - return ( -
- {/* Top Control Bar */} -
- {hasUnsavedChanges && ( -
- Unsaved Changes -
- )} - - {onClose && ( - - )} -
- - {/* Error Display */} - {error && ( -
-
-

- Error Loading Rapid Editor -

-

{error.message}

- {onClose && ( - - )} -
-
- )} - - {/* Loading Indicator */} - {isLoading && ( -
-
-
-
Loading Rapid Editor...
-
-
- )} - - {/* Rapid Editor Iframe */} -