diff --git a/src/pages/RepoPage/CoverageTab/subroute/FileExplorer/CodeTreeTable/CodeTreeTable.spec.tsx b/src/pages/RepoPage/CoverageTab/subroute/FileExplorer/CodeTreeTable/CodeTreeTable.spec.tsx index 5772428906..0d6e4628eb 100644 --- a/src/pages/RepoPage/CoverageTab/subroute/FileExplorer/CodeTreeTable/CodeTreeTable.spec.tsx +++ b/src/pages/RepoPage/CoverageTab/subroute/FileExplorer/CodeTreeTable/CodeTreeTable.spec.tsx @@ -20,6 +20,7 @@ const server = setupServer() const mockNoFiles = { username: 'nicholas-codecov', repository: { + __typename: 'Repository', branch: { head: { pathContents: { @@ -34,6 +35,7 @@ const mockNoFiles = { const mockMissingCoverage = { username: 'nicholas-codecov', repository: { + __typename: 'Repository', branch: { head: { pathContents: { @@ -48,6 +50,7 @@ const mockMissingCoverage = { const mockUnknownPath = { username: 'nicholas-codecov', repository: { + __typename: 'Repository', branch: { head: { pathContents: { @@ -62,6 +65,7 @@ const mockUnknownPath = { const mockTreeData = { username: 'codecov-tree', repository: { + __typename: 'Repository', branch: { head: { pathContents: { @@ -87,6 +91,7 @@ const mockTreeData = { const mockTreeDataNested = { username: 'codecov-tree', repository: { + __typename: 'Repository', branch: { head: { pathContents: { @@ -113,6 +118,7 @@ const mockTreeDataNested = { const mockNoHeadReport = { username: 'nicholas-codecov', repository: { + __typename: 'Repository', branch: { head: { pathContents: { diff --git a/src/pages/RepoPage/CoverageTab/subroute/FileExplorer/FileListTable/FileListTable.spec.tsx b/src/pages/RepoPage/CoverageTab/subroute/FileExplorer/FileListTable/FileListTable.spec.tsx index 1e6ec2a4f1..3c8ac7eedb 100644 --- a/src/pages/RepoPage/CoverageTab/subroute/FileExplorer/FileListTable/FileListTable.spec.tsx +++ b/src/pages/RepoPage/CoverageTab/subroute/FileExplorer/FileListTable/FileListTable.spec.tsx @@ -20,6 +20,7 @@ const server = setupServer() const mockNoFiles = { username: 'nicholas-codecov', repository: { + __typename: 'Repository', branch: { head: { pathContents: { @@ -34,6 +35,7 @@ const mockNoFiles = { const mockUnknownPath = { username: 'nicholas-codecov', repository: { + __typename: 'Repository', branch: { head: { pathContents: { @@ -48,6 +50,7 @@ const mockUnknownPath = { const mockMissingCoverage = { username: 'nicholas-codecov', repository: { + __typename: 'Repository', branch: { head: { pathContents: { @@ -62,6 +65,7 @@ const mockMissingCoverage = { const mockListData = { username: 'nicholas-codecov', repository: { + __typename: 'Repository', branch: { head: { pathContents: { @@ -88,6 +92,7 @@ const mockListData = { const mockNoHeadReport = { username: 'nicholas-codecov', repository: { + __typename: 'Repository', branch: { head: { pathContents: { diff --git a/src/pages/RepoPage/CoverageTab/subroute/FileExplorer/hooks/useRepoBranchContentsTable.spec.tsx b/src/pages/RepoPage/CoverageTab/subroute/FileExplorer/hooks/useRepoBranchContentsTable.spec.tsx index 4d71036f2d..00d1ab2fec 100644 --- a/src/pages/RepoPage/CoverageTab/subroute/FileExplorer/hooks/useRepoBranchContentsTable.spec.tsx +++ b/src/pages/RepoPage/CoverageTab/subroute/FileExplorer/hooks/useRepoBranchContentsTable.spec.tsx @@ -11,6 +11,7 @@ import { useRepoBranchContentsTable } from './useRepoBranchContentsTable' const mockBranchContentData = { owner: { repository: { + __typename: 'Repository', repositoryConfig: { indicationRange: { upperRange: 80, @@ -54,6 +55,7 @@ const mockBranchContentData = { const mockCommitNoContentData = { owner: { repository: { + __typename: 'Repository', repositoryConfig: { indicationRange: { upperRange: 80, diff --git a/src/services/pathContents/branch/dir/constants.js b/src/services/pathContents/branch/dir/constants.js deleted file mode 100644 index b06e9c0168..0000000000 --- a/src/services/pathContents/branch/dir/constants.js +++ /dev/null @@ -1,48 +0,0 @@ -export const query = ` - query BranchContents( - $name: String! - $repo: String! - $branch: String! - $path: String! - $filters: PathContentsFilters! - ) { - owner(username: $name) { - username - repository: repositoryDeprecated(name: $repo) { - repositoryConfig { - indicationRange { - upperRange - lowerRange - } - } - branch(name: $branch) { - head { - pathContents(path: $path, filters: $filters) { - ... on PathContents { - results { - __typename - hits - misses - partials - lines - name - path - percentCovered - ... on PathContentFile { - isCriticalFile - } - } - } - ... on UnknownPath { - message - } - ... on MissingCoverage { - message - } - __typename - } - } - } - } - } - }` diff --git a/src/services/pathContents/branch/dir/constants.ts b/src/services/pathContents/branch/dir/constants.ts new file mode 100644 index 0000000000..e7c2b54ae2 --- /dev/null +++ b/src/services/pathContents/branch/dir/constants.ts @@ -0,0 +1,57 @@ +export const query = ` +query BranchContents( + $name: String! + $repo: String! + $branch: String! + $path: String! + $filters: PathContentsFilters! +) { + owner(username: $name) { + username + repository(name: $repo) { + __typename + ... on Repository { + repositoryConfig { + indicationRange { + upperRange + lowerRange + } + } + branch(name: $branch) { + head { + pathContents(path: $path, filters: $filters) { + __typename + ... on PathContents { + results { + __typename + hits + misses + partials + lines + name + path + percentCovered + ... on PathContentFile { + isCriticalFile + } + } + } + ... on UnknownPath { + message + } + ... on MissingCoverage { + message + } + } + } + } + } + ... on NotFoundError { + message + } + ... on OwnerNotActivatedError { + message + } + } + } +}` diff --git a/src/services/pathContents/branch/dir/index.js b/src/services/pathContents/branch/dir/index.ts similarity index 100% rename from src/services/pathContents/branch/dir/index.js rename to src/services/pathContents/branch/dir/index.ts diff --git a/src/services/pathContents/branch/dir/usePrefetchBranchDirEntry.js b/src/services/pathContents/branch/dir/usePrefetchBranchDirEntry.js deleted file mode 100644 index b76ce82094..0000000000 --- a/src/services/pathContents/branch/dir/usePrefetchBranchDirEntry.js +++ /dev/null @@ -1,56 +0,0 @@ -import { useQueryClient } from '@tanstack/react-query' -import { useParams } from 'react-router-dom' - -import Api from 'shared/api' - -import { query } from './constants' - -export function usePrefetchBranchDirEntry({ branch, path, filters }) { - const { provider, owner, repo } = useParams() - const queryClient = useQueryClient() - - const runPrefetch = async () => - await queryClient.prefetchQuery({ - queryKey: [ - 'BranchContents', - provider, - owner, - repo, - branch, - path, - filters, - query, - ], - queryFn: () => - Api.graphql({ - provider, - repo, - query, - variables: { - name: owner, - repo, - branch, - path, - filters, - }, - }).then((res) => { - let results - if ( - res?.data?.owner?.repository?.branch?.head?.pathContents - ?.__typename === 'PathContents' - ) { - results = - res?.data?.owner?.repository?.branch?.head?.pathContents?.results - } - return { - results: results ?? null, - indicationRange: - res?.data?.owner?.repository?.repositoryConfig?.indicationRange, - __typename: res?.data?.owner?.repository?.branch?.head?.__typename, - } - }), - staleTime: 10000, - }) - - return { runPrefetch } -} diff --git a/src/services/pathContents/branch/dir/usePrefetchBranchDirEntry.spec.js b/src/services/pathContents/branch/dir/usePrefetchBranchDirEntry.spec.js deleted file mode 100644 index 29a00afc4e..0000000000 --- a/src/services/pathContents/branch/dir/usePrefetchBranchDirEntry.spec.js +++ /dev/null @@ -1,213 +0,0 @@ -import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import { renderHook, waitFor } from '@testing-library/react' -import { graphql } from 'msw' -import { setupServer } from 'msw/node' -import { MemoryRouter, Route } from 'react-router-dom' - -import { usePrefetchBranchDirEntry } from './usePrefetchBranchDirEntry' - -const queryClient = new QueryClient({ - defaultOptions: { queries: { retry: false } }, -}) -const wrapper = ({ children }) => ( - - - {children} - - -) - -const server = setupServer() -beforeAll(() => server.listen()) -beforeEach(() => { - server.resetHandlers() - queryClient.clear() -}) -afterAll(() => server.close()) - -const mockData = { - owner: { - username: 'codecov', - repository: { - repositoryConfig: { - indicationRange: { - upperRange: 80, - lowerRange: 60, - }, - }, - branch: { - head: { - pathContents: { - __typename: 'PathContents', - results: [ - { - __typename: 'PathContentDir', - name: 'src', - path: null, - percentCovered: 0.0, - hits: 4, - misses: 2, - lines: 7, - partials: 1, - }, - ], - }, - }, - }, - }, - }, -} - -const mockDataUnknownPath = { - owner: { - username: 'codecov', - repository: { - repositoryConfig: { - indicationRange: { - upperRange: 80, - lowerRange: 60, - }, - }, - branch: { - head: { - pathContents: { - message: 'path cannot be found', - __typename: 'UnknownPath', - }, - }, - }, - }, - }, -} - -const mockDataMissingCoverage = { - owner: { - username: 'codecov', - repository: { - repositoryConfig: { - indicationRange: { - upperRange: 80, - lowerRange: 60, - }, - }, - branch: { - head: { - pathContents: { - message: 'files missing coverage', - __typename: 'MissingCoverage', - }, - }, - }, - }, - }, -} - -describe('usePrefetchBranchDirEntry', () => { - function setup(isMissingCoverage = false, isUnknownPath = false) { - server.use( - graphql.query('BranchContents', (req, res, ctx) => { - if (isMissingCoverage) { - return res(ctx.status(200), ctx.data(mockDataMissingCoverage)) - } - if (isUnknownPath) { - return res(ctx.status(200), ctx.data(mockDataUnknownPath)) - } - return res(ctx.status(200), ctx.data(mockData)) - }) - ) - } - - beforeEach(async () => { - setup() - }) - - it('returns runPrefetch function', () => { - const { result } = renderHook( - () => usePrefetchBranchDirEntry({ branch: 'main', path: 'src' }), - { wrapper } - ) - - expect(result.current.runPrefetch).toBeDefined() - expect(typeof result.current.runPrefetch).toBe('function') - }) - - it('queries the api', async () => { - const { result } = renderHook( - () => usePrefetchBranchDirEntry({ branch: 'main', path: 'src' }), - { wrapper } - ) - - await result.current.runPrefetch() - await waitFor(() => queryClient.getQueryState().isFetching) - await waitFor(() => !queryClient.getQueryState().isFetching) - - expect(queryClient.getQueryState().data).toEqual( - expect.objectContaining({ - indicationRange: { - upperRange: 80, - lowerRange: 60, - }, - results: [ - { - __typename: 'PathContentDir', - name: 'src', - path: null, - percentCovered: 0, - hits: 4, - misses: 2, - lines: 7, - partials: 1, - }, - ], - }) - ) - }) - - describe('on missing coverage', () => { - it('returns no results', async () => { - setup(true) - const { result } = renderHook( - () => usePrefetchBranchDirEntry({ branch: 'main', path: 'src' }), - { wrapper } - ) - - await result.current.runPrefetch() - await waitFor(() => queryClient.getQueryState().isFetching) - await waitFor(() => !queryClient.getQueryState().isFetching) - - expect(queryClient.getQueryState().data).toEqual( - expect.objectContaining({ - indicationRange: { - upperRange: 80, - lowerRange: 60, - }, - results: null, - }) - ) - }) - }) - - describe('on unknown path', () => { - it('returns no results', async () => { - setup(false, true) - const { result } = renderHook( - () => usePrefetchBranchDirEntry({ branch: 'main', path: 'src' }), - { wrapper } - ) - - await result.current.runPrefetch() - await waitFor(() => queryClient.getQueryState().isFetching) - await waitFor(() => !queryClient.getQueryState().isFetching) - - expect(queryClient.getQueryState().data).toEqual( - expect.objectContaining({ - indicationRange: { - upperRange: 80, - lowerRange: 60, - }, - results: null, - }) - ) - }) - }) -}) diff --git a/src/services/pathContents/branch/dir/usePrefetchBranchDirEntry.spec.tsx b/src/services/pathContents/branch/dir/usePrefetchBranchDirEntry.spec.tsx new file mode 100644 index 0000000000..3d692590a5 --- /dev/null +++ b/src/services/pathContents/branch/dir/usePrefetchBranchDirEntry.spec.tsx @@ -0,0 +1,371 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { renderHook, waitFor } from '@testing-library/react' +import { graphql } from 'msw' +import { setupServer } from 'msw/node' +import { MemoryRouter, Route } from 'react-router-dom' + +import { usePrefetchBranchDirEntry } from './usePrefetchBranchDirEntry' + +const mockData = { + owner: { + username: 'codecov', + repository: { + __typename: 'Repository', + repositoryConfig: { + indicationRange: { + upperRange: 80, + lowerRange: 60, + }, + }, + branch: { + head: { + pathContents: { + __typename: 'PathContents', + results: [ + { + __typename: 'PathContentDir', + name: 'src', + path: null, + percentCovered: 0.0, + hits: 4, + misses: 2, + lines: 7, + partials: 1, + }, + ], + }, + }, + }, + }, + }, +} + +const mockDataUnknownPath = { + owner: { + username: 'codecov', + repository: { + __typename: 'Repository', + repositoryConfig: { + indicationRange: { + upperRange: 80, + lowerRange: 60, + }, + }, + branch: { + head: { + pathContents: { + message: 'path cannot be found', + __typename: 'UnknownPath', + }, + }, + }, + }, + }, +} + +const mockDataMissingCoverage = { + owner: { + username: 'codecov', + repository: { + __typename: 'Repository', + repositoryConfig: { + indicationRange: { + upperRange: 80, + lowerRange: 60, + }, + }, + branch: { + head: { + pathContents: { + message: 'files missing coverage', + __typename: 'MissingCoverage', + }, + }, + }, + }, + }, +} + +const mockDataRepositoryNotFound = { + owner: { + repository: { + __typename: 'NotFoundError', + message: 'repository not found', + }, + }, +} + +const mockDataOwnerNotActivated = { + owner: { + repository: { + __typename: 'OwnerNotActivatedError', + message: 'owner not activated', + }, + }, +} + +const mockUnsuccessfulParseError = {} + +const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, +}) + +const wrapper: React.FC = ({ children }) => ( + + + + {children} + + + +) + +const server = setupServer() +beforeAll(() => { + server.listen() +}) + +beforeEach(() => { + server.resetHandlers() + queryClient.clear() +}) + +afterAll(() => { + server.close() +}) + +interface SetupArgs { + isMissingCoverage?: boolean + isUnknownPath?: boolean + isRepositoryNotFoundError?: boolean + isOwnerNotActivatedError?: boolean + isUnsuccessfulParseError?: boolean +} + +describe('usePrefetchBranchDirEntry', () => { + function setup({ + isMissingCoverage = false, + isUnknownPath = false, + isRepositoryNotFoundError = false, + isOwnerNotActivatedError = false, + isUnsuccessfulParseError = false, + }: SetupArgs) { + server.use( + graphql.query('BranchContents', (req, res, ctx) => { + if (isMissingCoverage) { + return res(ctx.status(200), ctx.data(mockDataMissingCoverage)) + } else if (isUnknownPath) { + return res(ctx.status(200), ctx.data(mockDataUnknownPath)) + } else if (isRepositoryNotFoundError) { + return res(ctx.status(200), ctx.data(mockDataRepositoryNotFound)) + } else if (isOwnerNotActivatedError) { + return res(ctx.status(200), ctx.data(mockDataOwnerNotActivated)) + } else if (isUnsuccessfulParseError) { + return res(ctx.status(200), ctx.data(mockUnsuccessfulParseError)) + } + + return res(ctx.status(200), ctx.data(mockData)) + }) + ) + } + + it('returns runPrefetch function', () => { + setup({}) + const { result } = renderHook( + () => usePrefetchBranchDirEntry({ branch: 'main', path: 'src' }), + { wrapper } + ) + + expect(result.current.runPrefetch).toBeDefined() + expect(typeof result.current.runPrefetch).toBe('function') + }) + + it('queries the api', async () => { + setup({}) + const { result } = renderHook( + () => usePrefetchBranchDirEntry({ branch: 'main', path: 'src' }), + { wrapper } + ) + + await result.current.runPrefetch() + await waitFor(() => queryClient.isFetching()) + + const queryKey = queryClient + .getQueriesData({}) + ?.at(0) + ?.at(0) as Array + + await waitFor(() => + expect(queryClient?.getQueryData(queryKey)).toEqual( + expect.objectContaining({ + indicationRange: { + upperRange: 80, + lowerRange: 60, + }, + results: [ + { + __typename: 'PathContentDir', + name: 'src', + path: null, + percentCovered: 0, + hits: 4, + misses: 2, + lines: 7, + partials: 1, + }, + ], + }) + ) + ) + }) + + describe('on missing coverage', () => { + it('returns no results', async () => { + setup({ isMissingCoverage: true }) + const { result } = renderHook( + () => usePrefetchBranchDirEntry({ branch: 'main', path: 'src' }), + { wrapper } + ) + + await result.current.runPrefetch() + await waitFor(() => queryClient.isFetching()) + + const queryKey = queryClient + .getQueriesData({}) + ?.at(0) + ?.at(0) as Array + + await waitFor(() => + expect(queryClient?.getQueryData(queryKey)).toEqual( + expect.objectContaining({ + indicationRange: { + upperRange: 80, + lowerRange: 60, + }, + results: null, + }) + ) + ) + }) + }) + + describe('on unknown path', () => { + it('returns no results', async () => { + setup({ isUnknownPath: true }) + const { result } = renderHook( + () => usePrefetchBranchDirEntry({ branch: 'main', path: 'src' }), + { wrapper } + ) + + await result.current.runPrefetch() + await waitFor(() => queryClient.isFetching()) + + const queryKey = queryClient + .getQueriesData({}) + ?.at(0) + ?.at(0) as Array + + await waitFor(() => + expect(queryClient?.getQueryData(queryKey)).toEqual( + expect.objectContaining({ + indicationRange: { + upperRange: 80, + lowerRange: 60, + }, + results: null, + }) + ) + ) + }) + }) + + describe('rejecting request', () => { + let oldConsoleError = console.error + + beforeEach(() => { + console.error = () => null + }) + + afterEach(() => { + console.error = oldConsoleError + }) + + it('fails to parse bad schema', async () => { + setup({ isUnsuccessfulParseError: true }) + const { result } = renderHook( + () => usePrefetchBranchDirEntry({ branch: 'main', path: 'src' }), + { wrapper } + ) + + await result.current.runPrefetch() + + await waitFor(() => queryClient.isFetching()) + + const queryKey = queryClient + .getQueriesData({}) + ?.at(0) + ?.at(0) as Array + + await waitFor(() => + expect(queryClient?.getQueryState(queryKey)?.error).toEqual( + expect.objectContaining({ + status: 404, + dev: 'usePrefetchBranchDirEntry - 404 schema parsing failed', + }) + ) + ) + }) + + it('rejects on repository not found error', async () => { + setup({ isRepositoryNotFoundError: true }) + const { result } = renderHook( + () => usePrefetchBranchDirEntry({ branch: 'main', path: 'src' }), + { wrapper } + ) + + await result.current.runPrefetch() + + await waitFor(() => queryClient.isFetching()) + + const queryKey = queryClient + .getQueriesData({}) + ?.at(0) + ?.at(0) as Array + + await waitFor(() => + expect(queryClient?.getQueryState(queryKey)?.error).toEqual( + expect.objectContaining({ + status: 404, + dev: 'usePrefetchBranchDirEntry - 404 NotFoundError', + }) + ) + ) + }) + + it('rejects on owner not activated error', async () => { + setup({ isOwnerNotActivatedError: true }) + const { result } = renderHook( + () => usePrefetchBranchDirEntry({ branch: 'main', path: 'src' }), + { wrapper } + ) + + await result.current.runPrefetch() + + await waitFor(() => queryClient.isFetching()) + + const queryKey = queryClient + .getQueriesData({}) + ?.at(0) + ?.at(0) as Array + + await waitFor(() => + expect(queryClient?.getQueryState(queryKey)?.error).toEqual( + expect.objectContaining({ + status: 403, + dev: 'usePrefetchBranchDirEntry - 403 OwnerNotActivatedError', + }) + ) + ) + }) + }) +}) diff --git a/src/services/pathContents/branch/dir/usePrefetchBranchDirEntry.tsx b/src/services/pathContents/branch/dir/usePrefetchBranchDirEntry.tsx new file mode 100644 index 0000000000..394fd37f61 --- /dev/null +++ b/src/services/pathContents/branch/dir/usePrefetchBranchDirEntry.tsx @@ -0,0 +1,204 @@ +import { useQueryClient } from '@tanstack/react-query' +import { useParams } from 'react-router-dom' +import { z } from 'zod' + +import { UnknownFlagsSchema } from 'services/impactedFiles/schemas' +import { + RepoNotFoundErrorSchema, + RepoOwnerNotActivatedErrorSchema, +} from 'services/repo' +import { RepoConfig } from 'services/repo/useRepoConfig' +import Api from 'shared/api' +import { NetworkErrorObject } from 'shared/api/helpers' +import A from 'ui/A' + +import { query } from './constants' + +const BasePathContentSchema = z.object({ + hits: z.number(), + misses: z.number(), + partials: z.number(), + lines: z.number(), + name: z.string(), + path: z.string().nullable(), + percentCovered: z.number(), +}) + +const PathContentFileSchema = BasePathContentSchema.extend({ + __typename: z.literal('PathContentFile'), + isCriticalFile: z.boolean(), +}) + +const PathContentDirSchema = BasePathContentSchema.extend({ + __typename: z.literal('PathContentDir'), +}) + +const PathContentsResultSchema = z.discriminatedUnion('__typename', [ + PathContentFileSchema, + PathContentDirSchema, +]) + +const PathContentsSchema = z.object({ + __typename: z.literal('PathContents'), + results: z.array(PathContentsResultSchema), +}) + +const UnknownPathSchema = z.object({ + __typename: z.literal('UnknownPath'), + message: z.string().nullish(), +}) + +const MissingCoverageSchema = z.object({ + __typename: z.literal('MissingCoverage'), + message: z.string().nullish(), +}) + +const MissingHeadReportSchema = z.object({ + __typename: z.literal('MissingHeadReport'), + message: z.string().nullish(), +}) + +const PathContentsUnionSchema = z.discriminatedUnion('__typename', [ + PathContentsSchema, + UnknownPathSchema, + MissingCoverageSchema, + MissingHeadReportSchema, + UnknownFlagsSchema, +]) + +const RepositorySchema = z.object({ + __typename: z.literal('Repository'), + repositoryConfig: RepoConfig, + branch: z.object({ + head: z + .object({ + pathContents: PathContentsUnionSchema.nullish(), + }) + .nullable(), + }), +}) + +const BranchContentsSchema = z.object({ + owner: z + .object({ + repository: z.discriminatedUnion('__typename', [ + RepositorySchema, + RepoNotFoundErrorSchema, + RepoOwnerNotActivatedErrorSchema, + ]), + }) + .nullable(), +}) + +interface URLParams { + provider: string + owner: string + repo: string +} + +interface UsePrefetchBranchDirEntryArgs { + branch: string + path: string + filters?: { + searchValue?: string + displayType?: string + ordering?: string + flags?: string[] + components?: string[] + } +} + +export function usePrefetchBranchDirEntry({ + branch, + path, + filters, +}: UsePrefetchBranchDirEntryArgs) { + const { provider, owner, repo } = useParams() + const queryClient = useQueryClient() + + const runPrefetch = async () => { + await queryClient.prefetchQuery({ + queryKey: [ + 'BranchContents', + provider, + owner, + repo, + branch, + path, + filters, + ], + queryFn: ({ signal }) => + Api.graphql({ + provider, + query, + signal, + variables: { + name: owner, + repo, + branch, + path, + filters, + }, + }).then((res) => { + const parsedRes = BranchContentsSchema.safeParse(res?.data) + + if (!parsedRes.success) { + return Promise.reject({ + status: 404, + data: {}, + dev: 'usePrefetchBranchDirEntry - 404 schema parsing failed', + } satisfies NetworkErrorObject) + } + + const data = parsedRes.data + + if (data?.owner?.repository?.__typename === 'NotFoundError') { + return Promise.reject({ + status: 404, + data: {}, + dev: 'usePrefetchBranchDirEntry - 404 NotFoundError', + } satisfies NetworkErrorObject) + } + + if ( + data?.owner?.repository?.__typename === 'OwnerNotActivatedError' + ) { + return Promise.reject({ + status: 403, + data: { + detail: ( +

+ Activation is required to view this repo, please{' '} + {/* @ts-expect-error */} + click here to + activate your account. +

+ ), + }, + dev: 'usePrefetchBranchDirEntry - 403 OwnerNotActivatedError', + } satisfies NetworkErrorObject) + } + + let results + if ( + data?.owner?.repository?.branch?.head?.pathContents?.__typename === + 'PathContents' + ) { + results = + data?.owner?.repository?.branch?.head?.pathContents?.results + } + + return { + __typename: + data?.owner?.repository?.branch?.head?.pathContents?.__typename, + results: results ?? null, + indicationRange: + data?.owner?.repository?.repositoryConfig?.indicationRange, + } + }), + staleTime: 10000, + }) + } + + return { runPrefetch } +} diff --git a/src/services/pathContents/branch/dir/useRepoBranchContents.spec.tsx b/src/services/pathContents/branch/dir/useRepoBranchContents.spec.tsx index abdeb0aaf8..1c7e73ff86 100644 --- a/src/services/pathContents/branch/dir/useRepoBranchContents.spec.tsx +++ b/src/services/pathContents/branch/dir/useRepoBranchContents.spec.tsx @@ -4,33 +4,13 @@ import { graphql } from 'msw' import { setupServer } from 'msw/node' import { MemoryRouter, Route } from 'react-router-dom' -import { useRepoBranchContents } from './index' +import { useRepoBranchContents } from './useRepoBranchContents' -const queryClient = new QueryClient({ - defaultOptions: { queries: { retry: false } }, -}) - -const wrapper: React.FC = ({ children }) => ( - - - {children} - - -) - -const server = setupServer() - -beforeAll(() => server.listen()) -afterEach(() => { - queryClient.clear() - server.resetHandlers() -}) -afterAll(() => server.close()) - -const dataReturned = { +const mockData = { owner: { - username: 'Rabee-AbuBaker', + username: 'cool-user', repository: { + __typename: 'Repository', repositoryConfig: { indicationRange: { upperRange: 80, @@ -64,6 +44,7 @@ const mockDataUnknownPath = { owner: { username: 'codecov', repository: { + __typename: 'Repository', repositoryConfig: { indicationRange: { upperRange: 80, @@ -86,6 +67,7 @@ const mockDataMissingCoverage = { owner: { username: 'codecov', repository: { + __typename: 'Repository', repositoryConfig: { indicationRange: { upperRange: 80, @@ -104,51 +86,97 @@ const mockDataMissingCoverage = { }, } +const mockDataRepositoryNotFound = { + owner: { + repository: { + __typename: 'NotFoundError', + message: 'repository not found', + }, + }, +} + +const mockDataOwnerNotActivated = { + owner: { + repository: { + __typename: 'OwnerNotActivatedError', + message: 'owner not activated', + }, + }, +} + +const mockUnsuccessfulParseError = {} + +const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, +}) + +const wrapper: React.FC = ({ children }) => ( + + + {children} + + +) + +const server = setupServer() + +beforeAll(() => { + server.listen() +}) + +afterEach(() => { + queryClient.clear() + server.resetHandlers() +}) + +afterAll(() => { + server.close() +}) + +interface SetupArgs { + isMissingCoverage?: boolean + isUnknownPath?: boolean + isRepositoryNotFoundError?: boolean + isOwnerNotActivatedError?: boolean + isUnsuccessfulParseError?: boolean +} + describe('useRepoBranchContents', () => { - function setup(isMissingCoverage = false, isUnknownPath = false) { + function setup({ + isMissingCoverage = false, + isUnknownPath = false, + isOwnerNotActivatedError = false, + isRepositoryNotFoundError = false, + isUnsuccessfulParseError = false, + }: SetupArgs) { server.use( graphql.query('BranchContents', (req, res, ctx) => { if (isMissingCoverage) { return res(ctx.status(200), ctx.data(mockDataMissingCoverage)) - } - if (isUnknownPath) { + } else if (isUnknownPath) { return res(ctx.status(200), ctx.data(mockDataUnknownPath)) + } else if (isRepositoryNotFoundError) { + return res(ctx.status(200), ctx.data(mockDataRepositoryNotFound)) + } else if (isOwnerNotActivatedError) { + return res(ctx.status(200), ctx.data(mockDataOwnerNotActivated)) + } else if (isUnsuccessfulParseError) { + return res(ctx.status(200), ctx.data(mockUnsuccessfulParseError)) } - return res(ctx.status(200), ctx.data(dataReturned)) + + return res(ctx.status(200), ctx.data(mockData)) }) ) } describe('when called', () => { - beforeEach(() => { - setup() - }) - - it('renders isLoading true', () => { - const { result } = renderHook( - () => - useRepoBranchContents({ - provider: 'gh', - owner: 'Rabee-AbuBaker', - repo: 'another-test', - branch: 'main', - path: '', - }), - { - wrapper, - } - ) - - expect(result.current.isLoading).toBeTruthy() - }) - describe('when data is loaded', () => { it('returns the data', async () => { + setup({}) const { result } = renderHook( () => useRepoBranchContents({ provider: 'gh', - owner: 'Rabee-AbuBaker', + owner: 'cool-user', repo: 'another-test', branch: 'main', path: '', @@ -158,40 +186,39 @@ describe('useRepoBranchContents', () => { } ) - await waitFor(() => result.current.isLoading) - await waitFor(() => !result.current.isLoading) - await waitFor(() => result.current.isSuccess) - expect(result.current.data).toEqual( - expect.objectContaining({ - results: [ - { - __typename: 'PathContentDir', - hits: 9, - misses: 0, - partials: 0, - lines: 10, - name: 'src', - path: 'src', - percentCovered: 100.0, + await waitFor(() => + expect(result.current.data).toEqual( + expect.objectContaining({ + results: [ + { + __typename: 'PathContentDir', + hits: 9, + misses: 0, + partials: 0, + lines: 10, + name: 'src', + path: 'src', + percentCovered: 100.0, + }, + ], + indicationRange: { + upperRange: 80, + lowerRange: 60, }, - ], - indicationRange: { - upperRange: 80, - lowerRange: 60, - }, - }) + }) + ) ) }) }) describe('on missing coverage', () => { it('returns no results', async () => { - setup(true) + setup({ isMissingCoverage: true }) const { result } = renderHook( () => useRepoBranchContents({ provider: 'gh', - owner: 'Rabee-AbuBaker', + owner: 'cool-user', repo: 'another-test', branch: 'main', path: '', @@ -201,31 +228,29 @@ describe('useRepoBranchContents', () => { } ) - await waitFor(() => result.current.isLoading) - await waitFor(() => !result.current.isLoading) - await waitFor(() => result.current.isSuccess) - - expect(result.current.data).toEqual( - expect.objectContaining({ - indicationRange: { - upperRange: 80, - lowerRange: 60, - }, - results: null, - pathContentsType: 'MissingCoverage', - }) + await waitFor(() => + expect(result.current.data).toEqual( + expect.objectContaining({ + indicationRange: { + upperRange: 80, + lowerRange: 60, + }, + results: null, + pathContentsType: 'MissingCoverage', + }) + ) ) }) }) describe('on unknown path', () => { it('returns no results', async () => { - setup(false, true) + setup({ isUnknownPath: true }) const { result } = renderHook( () => useRepoBranchContents({ provider: 'gh', - owner: 'Rabee-AbuBaker', + owner: 'cool-user', repo: 'another-test', branch: 'main', path: '', @@ -235,21 +260,115 @@ describe('useRepoBranchContents', () => { } ) - await waitFor(() => result.current.isLoading) - await waitFor(() => !result.current.isLoading) - await waitFor(() => result.current.isSuccess) - - expect(result.current.data).toEqual( - expect.objectContaining({ - indicationRange: { - upperRange: 80, - lowerRange: 60, - }, - results: null, - pathContentsType: 'UnknownPath', - }) + await waitFor(() => + expect(result.current.data).toEqual( + expect.objectContaining({ + indicationRange: { + upperRange: 80, + lowerRange: 60, + }, + results: null, + pathContentsType: 'UnknownPath', + }) + ) ) }) }) + + describe('request rejects', () => { + let oldConsoleError = console.error + + beforeEach(() => { + console.error = () => null + }) + + afterEach(() => { + console.error = oldConsoleError + }) + + describe('on repository not found', () => { + it('rejects to repository not found error', async () => { + setup({ isUnsuccessfulParseError: true }) + const { result } = renderHook( + () => + useRepoBranchContents({ + provider: 'gh', + owner: 'cool-user', + repo: 'another-test', + branch: 'main', + path: '', + }), + { + wrapper, + } + ) + + await waitFor(() => + expect(result.current.error).toEqual( + expect.objectContaining({ + status: 404, + dev: 'useRepoBranchContents - 404 schema parsing failed', + }) + ) + ) + }) + }) + + describe('on owner not activated', () => { + it('rejects to owner not activated error', async () => { + setup({ isRepositoryNotFoundError: true }) + const { result } = renderHook( + () => + useRepoBranchContents({ + provider: 'gh', + owner: 'cool-user', + repo: 'another-test', + branch: 'main', + path: '', + }), + { + wrapper, + } + ) + + await waitFor(() => + expect(result.current.error).toEqual( + expect.objectContaining({ + status: 404, + dev: 'useRepoBranchContents - 404 NotFoundError', + }) + ) + ) + }) + }) + + describe('failing to parse schema', () => { + it('rejects to unknown error', async () => { + setup({ isOwnerNotActivatedError: true }) + const { result } = renderHook( + () => + useRepoBranchContents({ + provider: 'gh', + owner: 'cool-user', + repo: 'another-test', + branch: 'main', + path: '', + }), + { + wrapper, + } + ) + + await waitFor(() => + expect(result.current.error).toEqual( + expect.objectContaining({ + status: 403, + dev: 'useRepoBranchContents - 403 OwnerNotActivatedError', + }) + ) + ) + }) + }) + }) }) }) diff --git a/src/services/pathContents/branch/dir/useRepoBranchContents.tsx b/src/services/pathContents/branch/dir/useRepoBranchContents.tsx index 4e21c2c7e5..f47dd23314 100644 --- a/src/services/pathContents/branch/dir/useRepoBranchContents.tsx +++ b/src/services/pathContents/branch/dir/useRepoBranchContents.tsx @@ -2,21 +2,17 @@ import { useQuery } from '@tanstack/react-query' import { z } from 'zod' import { UnknownFlagsSchema } from 'services/impactedFiles/schemas' +import { + RepoNotFoundErrorSchema, + RepoOwnerNotActivatedErrorSchema, +} from 'services/repo' import { RepoConfig } from 'services/repo/useRepoConfig' import Api from 'shared/api' +import { NetworkErrorObject } from 'shared/api/helpers' +import A from 'ui/A' import { query } from './constants' -interface FetchRepoContentsArgs { - provider: string - owner: string - repo: string - branch: string - path: string - filters?: {} - signal?: AbortSignal -} - const BasePathContentSchema = z.object({ hits: z.number(), misses: z.number(), @@ -73,69 +69,30 @@ const PathContentsUnionSchema = z.discriminatedUnion('__typename', [ export type PathContentResultType = z.infer +const RepositorySchema = z.object({ + __typename: z.literal('Repository'), + repositoryConfig: RepoConfig, + branch: z.object({ + head: z + .object({ + pathContents: PathContentsUnionSchema.nullish(), + }) + .nullable(), + }), +}) + const BranchContentsSchema = z.object({ owner: z .object({ - repository: z.object({ - repositoryConfig: RepoConfig, - branch: z.object({ - head: z - .object({ - pathContents: PathContentsUnionSchema.nullish(), - }) - .nullable(), - }), - }), + repository: z.discriminatedUnion('__typename', [ + RepositorySchema, + RepoNotFoundErrorSchema, + RepoOwnerNotActivatedErrorSchema, + ]), }) .nullable(), }) -function fetchRepoContents({ - provider, - owner, - repo, - branch, - path, - filters, - signal, -}: FetchRepoContentsArgs) { - return Api.graphql({ - provider, - query, - signal, - variables: { - name: owner, - repo, - branch, - path, - filters, - }, - }).then((res) => { - const parsedData = BranchContentsSchema.safeParse(res?.data) - - if (!parsedData.success) { - return null - } - - let results - const pathContentsType = - parsedData?.data?.owner?.repository?.branch?.head?.pathContents - ?.__typename - if (pathContentsType === 'PathContents') { - results = - parsedData?.data?.owner?.repository?.branch?.head?.pathContents?.results - } - - return { - results: results ?? null, - pathContentsType, - indicationRange: - parsedData?.data?.owner?.repository?.repositoryConfig?.indicationRange, - __typename: res?.data?.owner?.repository?.branch?.head?.__typename, - } - }) -} - interface RepoBranchContentsArgs { provider: string owner: string @@ -159,26 +116,73 @@ export function useRepoBranchContents({ options, }: RepoBranchContentsArgs) { return useQuery({ - queryKey: [ - 'BranchContents', - provider, - owner, - repo, - branch, - path, - filters, - query, - ], - queryFn: ({ signal }) => - fetchRepoContents({ + queryKey: ['BranchContents', provider, owner, repo, branch, path, filters], + queryFn: ({ signal }) => { + return Api.graphql({ provider, - owner, - repo, - branch, - path, - filters, + query, signal, - }), + variables: { + name: owner, + repo, + branch, + path, + filters, + }, + }).then((res) => { + const parsedRes = BranchContentsSchema.safeParse(res?.data) + + if (!parsedRes.success) { + return Promise.reject({ + status: 404, + data: {}, + dev: 'useRepoBranchContents - 404 schema parsing failed', + } satisfies NetworkErrorObject) + } + + const data = parsedRes.data + + if (data?.owner?.repository?.__typename === 'NotFoundError') { + return Promise.reject({ + status: 404, + data: {}, + dev: 'useRepoBranchContents - 404 NotFoundError', + } satisfies NetworkErrorObject) + } + + if (data?.owner?.repository?.__typename === 'OwnerNotActivatedError') { + return Promise.reject({ + status: 403, + data: { + detail: ( +

+ Activation is required to view this repo, please{' '} + {/* @ts-expect-error */} + click here to activate + your account. +

+ ), + }, + dev: 'useRepoBranchContents - 403 OwnerNotActivatedError', + } satisfies NetworkErrorObject) + } + + let results + const pathContentsType = + data?.owner?.repository?.branch?.head?.pathContents?.__typename + if (pathContentsType === 'PathContents') { + results = data?.owner?.repository?.branch?.head?.pathContents?.results + } + + return { + results: results ?? null, + pathContentsType, + indicationRange: + data?.owner?.repository?.repositoryConfig?.indicationRange, + __typename: res?.data?.owner?.repository?.branch?.head?.__typename, + } + }) + }, ...options, }) } diff --git a/src/shared/ContentsTable/TableEntries/BranchEntries/BranchDirEntry.spec.jsx b/src/shared/ContentsTable/TableEntries/BranchEntries/BranchDirEntry.spec.jsx index c4fd2071f9..fdf758c6a3 100644 --- a/src/shared/ContentsTable/TableEntries/BranchEntries/BranchDirEntry.spec.jsx +++ b/src/shared/ContentsTable/TableEntries/BranchEntries/BranchDirEntry.spec.jsx @@ -11,6 +11,7 @@ const mockData = { owner: { username: 'codecov', repository: { + __typename: 'Repository', repositoryConfig: { indicationRange: { upperRange: 80, @@ -173,7 +174,7 @@ describe('BranchDirEntry', () => { await waitFor(() => expect(queryClient.getQueryState().data).toStrictEqual({ - __typename: undefined, + __typename: 'PathContents', indicationRange: { upperRange: 80, lowerRange: 60,