From 8124fd1923144cf865c186f51d6ac6873d870193 Mon Sep 17 00:00:00 2001 From: JerrySentry Date: Tue, 5 Dec 2023 18:36:48 -0800 Subject: [PATCH 1/2] feat: Add hook for get components of a repo/branch with filtering (#827) --- src/services/branches/index.js | 1 + .../branches/useBranchComponents.spec.tsx | 328 ++++++++++++++++++ src/services/branches/useBranchComponents.tsx | 150 ++++++++ 3 files changed, 479 insertions(+) create mode 100644 src/services/branches/useBranchComponents.spec.tsx create mode 100644 src/services/branches/useBranchComponents.tsx diff --git a/src/services/branches/index.js b/src/services/branches/index.js index fafff3aac1..43ffd167f8 100644 --- a/src/services/branches/index.js +++ b/src/services/branches/index.js @@ -1,2 +1,3 @@ export * from './useBranch' export * from './useBranches' +export * from './useBranchComponents' diff --git a/src/services/branches/useBranchComponents.spec.tsx b/src/services/branches/useBranchComponents.spec.tsx new file mode 100644 index 0000000000..ebd8f055c1 --- /dev/null +++ b/src/services/branches/useBranchComponents.spec.tsx @@ -0,0 +1,328 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { renderHook, waitFor } from '@testing-library/react' +import { graphql } from 'msw' +import { setupServer } from 'msw/node' + +import { useBranchComponents } from './useBranchComponents' + +const mockBranchComponents = { + owner: { + repository: { + __typename: 'Repository', + branch: { + name: 'main', + head: { + commitid: 'commit-123', + components: [ + { + id: 'compOneId', + name: 'compOneName', + }, + { + id: 'compTwoId', + name: 'compTwoName', + }, + ], + }, + }, + }, + }, +} + +const mockBranchComponentsFiltered = { + owner: { + repository: { + __typename: 'Repository', + branch: { + name: 'main', + head: { + commitid: 'commit-123', + components: [ + { + id: 'compOneId', + name: 'compOneName', + }, + ], + }, + }, + }, + }, +} + +const mockNotFoundError = { + owner: { + isCurrentUserPartOfOrg: true, + repository: { + __typename: 'NotFoundError', + message: 'commit not found', + }, + }, +} + +const mockOwnerNotActivatedError = { + owner: { + isCurrentUserPartOfOrg: true, + repository: { + __typename: 'OwnerNotActivatedError', + message: 'owner not activated', + }, + }, +} + +const mockNullOwner = { + owner: null, +} + +const mockUnsuccessfulParseError = {} + +const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, +}) +const server = setupServer() + +const wrapper: React.FC = ({ children }) => ( + {children} +) + +beforeAll(() => { + server.listen() +}) +afterEach(() => { + queryClient.clear() + server.resetHandlers() +}) +afterAll(() => { + server.close() +}) + +interface SetupArgs { + isNotFoundError?: boolean + isOwnerNotActivatedError?: boolean + isUnsuccessfulParseError?: boolean + isNullOwner?: boolean + isFiltered?: boolean +} + +describe('useBranchComponents', () => { + function setup({ + isNotFoundError = false, + isOwnerNotActivatedError = false, + isUnsuccessfulParseError = false, + isNullOwner = false, + isFiltered = false, + }: SetupArgs) { + server.use( + graphql.query('GetBranchComponents', (req, res, ctx) => { + if (isNotFoundError) { + return res(ctx.status(200), ctx.data(mockNotFoundError)) + } else if (isOwnerNotActivatedError) { + return res(ctx.status(200), ctx.data(mockOwnerNotActivatedError)) + } else if (isUnsuccessfulParseError) { + return res(ctx.status(200), ctx.data(mockUnsuccessfulParseError)) + } else if (isNullOwner) { + return res(ctx.status(200), ctx.data(mockNullOwner)) + } else if (isFiltered) { + return res(ctx.status(200), ctx.data(mockBranchComponentsFiltered)) + } else { + return res(ctx.status(200), ctx.data(mockBranchComponents)) + } + }) + ) + } + + describe('calling hook', () => { + describe('returns repository typename of Repository', () => { + describe('there is valid data', () => { + it('fetches the branch data without filtering', async () => { + setup({}) + const { result } = renderHook( + () => + useBranchComponents({ + provider: 'gh', + owner: 'codecov', + repo: 'cool-repo', + branch: 'main', + }), + { wrapper } + ) + + await waitFor(() => + expect(result.current.data).toStrictEqual({ + branch: { + head: { + components: [ + { + id: 'compOneId', + name: 'compOneName', + }, + { + id: 'compTwoId', + name: 'compTwoName', + }, + ], + }, + }, + }) + ) + }) + + it('fetches the branch data filtering', async () => { + setup({ isFiltered: true }) + const { result } = renderHook( + () => + useBranchComponents({ + provider: 'gh', + owner: 'codecov', + repo: 'cool-repo', + branch: 'main', + filters: { components: ['componename'] }, + }), + { wrapper } + ) + + await waitFor(() => + expect(result.current.data).toStrictEqual({ + branch: { + head: { + components: [ + { + id: 'compOneId', + name: 'compOneName', + }, + ], + }, + }, + }) + ) + }) + }) + + describe('there is a null owner', () => { + it('returns a null value', async () => { + setup({ isNullOwner: true }) + const { result } = renderHook( + () => + useBranchComponents({ + provider: 'gh', + owner: 'codecov', + repo: 'cool-repo', + branch: 'main', + }), + { wrapper } + ) + + await waitFor(() => + expect(result.current.data).toStrictEqual({ + branch: null, + }) + ) + }) + }) + }) + + describe('returns NotFoundError __typename', () => { + let oldConsoleError = console.error + + beforeEach(() => { + console.error = () => null + }) + + afterEach(() => { + console.error = oldConsoleError + }) + + it('throws a 404', async () => { + setup({ isNotFoundError: true }) + const { result } = renderHook( + () => + useBranchComponents({ + provider: 'gh', + owner: 'codecov', + repo: 'cool-repo', + branch: 'main', + }), + { wrapper } + ) + + await waitFor(() => expect(result.current.isError).toBeTruthy()) + await waitFor(() => + expect(result.current.error).toEqual( + expect.objectContaining({ + status: 404, + }) + ) + ) + }) + }) + + describe('returns OwnerNotActivatedError __typename', () => { + let oldConsoleError = console.error + + beforeEach(() => { + console.error = () => null + }) + + afterEach(() => { + console.error = oldConsoleError + }) + + it('throws a 403', async () => { + setup({ isOwnerNotActivatedError: true }) + const { result } = renderHook( + () => + useBranchComponents({ + provider: 'gh', + owner: 'codecov', + repo: 'cool-repo', + branch: 'main', + }), + { wrapper } + ) + + await waitFor(() => expect(result.current.isError).toBeTruthy()) + await waitFor(() => + expect(result.current.error).toEqual( + expect.objectContaining({ + status: 403, + }) + ) + ) + }) + }) + + describe('unsuccessful parse of zod schema', () => { + let oldConsoleError = console.error + + beforeEach(() => { + console.error = () => null + }) + + afterEach(() => { + console.error = oldConsoleError + }) + + it('throws a 404', async () => { + setup({ isUnsuccessfulParseError: true }) + const { result } = renderHook( + () => + useBranchComponents({ + provider: 'gh', + owner: 'codecov', + repo: 'cool-repo', + branch: 'main', + }), + { wrapper } + ) + + await waitFor(() => expect(result.current.isError).toBeTruthy()) + await waitFor(() => + expect(result.current.error).toEqual( + expect.objectContaining({ + status: 404, + }) + ) + ) + }) + }) + }) +}) diff --git a/src/services/branches/useBranchComponents.tsx b/src/services/branches/useBranchComponents.tsx new file mode 100644 index 0000000000..718e0c3180 --- /dev/null +++ b/src/services/branches/useBranchComponents.tsx @@ -0,0 +1,150 @@ +import { useQuery, UseQueryOptions } from '@tanstack/react-query' +import { z } from 'zod' + +import { + RepoNotFoundErrorSchema, + RepoOwnerNotActivatedErrorSchema, +} from 'services/repo' +import Api from 'shared/api' +import A from 'ui/A' + +const BranchComponentsSchema = z + .object({ + head: z + .object({ + components: z.array( + z.object({ + id: z.string(), + name: z.string(), + }) + ), + }) + .nullable(), + }) + .nullable() + +type BranchComponentsData = z.infer + +const GetBranchComponentsSchema = z.object({ + owner: z + .object({ + repository: z + .discriminatedUnion('__typename', [ + z.object({ + __typename: z.literal('Repository'), + branch: BranchComponentsSchema, + }), + RepoNotFoundErrorSchema, + RepoOwnerNotActivatedErrorSchema, + ]) + .nullable(), + }) + .nullable(), +}) + +interface UseBranchComponentsArgs { + provider: string + owner: string + repo: string + branch: string + filters?: { + components?: string[] + } + opts?: UseQueryOptions<{ branch: BranchComponentsData }> +} + +const query = ` +query GetBranchComponents($owner: String!, $repo: String!, $branch: String!, $filter: ComponentsFilters) { + owner(username: $owner) { + repository(name: $repo) { + __typename + ... on Repository { + branch(name: $branch) { + head { + components (filters: $filters) { + id + name + } + } + } + } + ... on NotFoundError { + message + } + ... on OwnerNotActivatedError { + message + } + } + } +}` + +export const useBranchComponents = ({ + provider, + owner, + repo, + branch, + filters, + opts, +}: UseBranchComponentsArgs) => + useQuery({ + queryKey: [ + 'GetBranchComponents', + provider, + owner, + repo, + branch, + filters, + query, + ], + queryFn: ({ signal }) => + Api.graphql({ + provider, + query, + signal, + variables: { + owner, + repo, + branch, + filters, + }, + }).then((res) => { + const parsedData = GetBranchComponentsSchema.safeParse(res?.data) + + if (!parsedData.success) { + return Promise.reject({ + status: 404, + data: {}, + }) + } + + const data = parsedData.data + + if (data?.owner?.repository?.__typename === 'NotFoundError') { + return Promise.reject({ + status: 404, + data: {}, + }) + } + + 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. +

+ ), + }, + }) + } + + return { + branch: data?.owner?.repository?.branch ?? null, + } + }), + ...(!!opts && opts), + }) From 709672e7f1b9843e208ea84b13f135753498dee8 Mon Sep 17 00:00:00 2001 From: JerrySentry Date: Fri, 8 Dec 2023 07:23:25 -0800 Subject: [PATCH 2/2] code review feedback --- src/services/branches/useBranchComponents.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/services/branches/useBranchComponents.tsx b/src/services/branches/useBranchComponents.tsx index 718e0c3180..2f1699e336 100644 --- a/src/services/branches/useBranchComponents.tsx +++ b/src/services/branches/useBranchComponents.tsx @@ -54,7 +54,7 @@ interface UseBranchComponentsArgs { } const query = ` -query GetBranchComponents($owner: String!, $repo: String!, $branch: String!, $filter: ComponentsFilters) { +query GetBranchComponents($owner: String!, $repo: String!, $branch: String!, $filters: ComponentsFilters) { owner(username: $owner) { repository(name: $repo) { __typename