From 0bac782bd027ba75c6754a200704ba7ffa6eb463 Mon Sep 17 00:00:00 2001 From: neo773 <62795688+neo773@users.noreply.github.com> Date: Thu, 24 Nov 2022 02:34:09 +0530 Subject: [PATCH] port react tests to v10 (#3239) --- packages/tests/server/react/__testHelpers.tsx | 251 +++++++++++ .../tests/server/react/dehydrate.test.tsx | 46 ++ .../tests/server/react/formatError.test.tsx | 66 +++ .../tests/server/react/infiniteQuery.test.tsx | 397 ++++++++++++++++++ .../server/react/invalidateQueries.test.tsx | 234 +++++++++++ .../tests/server/react/prefetchQuery.test.tsx | 55 +++ .../issue-1645-setErrorStatusSSR.test.tsx | 67 +++ .../react/setInfiniteQueryData.test.tsx | 113 +++++ .../tests/server/react/setQueryData.test.tsx | 91 ++++ 9 files changed, 1320 insertions(+) create mode 100644 packages/tests/server/react/__testHelpers.tsx create mode 100644 packages/tests/server/react/dehydrate.test.tsx create mode 100644 packages/tests/server/react/formatError.test.tsx create mode 100644 packages/tests/server/react/infiniteQuery.test.tsx create mode 100644 packages/tests/server/react/invalidateQueries.test.tsx create mode 100644 packages/tests/server/react/prefetchQuery.test.tsx create mode 100644 packages/tests/server/react/regression/issue-1645-setErrorStatusSSR.test.tsx create mode 100644 packages/tests/server/react/setInfiniteQueryData.test.tsx create mode 100644 packages/tests/server/react/setQueryData.test.tsx diff --git a/packages/tests/server/react/__testHelpers.tsx b/packages/tests/server/react/__testHelpers.tsx new file mode 100644 index 00000000000..09f52b095a0 --- /dev/null +++ b/packages/tests/server/react/__testHelpers.tsx @@ -0,0 +1,251 @@ +/* eslint-disable @typescript-eslint/no-empty-function */ +import { routerToServerAndClientNew } from '../___testHelpers'; +import { createQueryClient, createQueryClientConfig } from '../__queryClient'; +import { QueryClientProvider } from '@tanstack/react-query'; +import { + TRPCWebSocketClient, + createWSClient, + httpBatchLink, + splitLink, + wsLink, +} from '@trpc/client'; +import { createTRPCReact } from '@trpc/react-query'; +import { OutputWithCursor } from '@trpc/react-query/src/shared/hooks/createHooksInternal'; +import { TRPCError, initTRPC } from '@trpc/server'; +import { observable } from '@trpc/server/src/observable'; +import { subscriptionPullFactory } from '@trpc/server/src/subscription'; +import hash from 'hash-sum'; +import React, { ReactNode } from 'react'; +import { ZodError, z } from 'zod'; + +export type Post = { + id: string; + title: string; + createdAt: number; +}; + +export function createAppRouter() { + const db: { + posts: Post[]; + } = { + posts: [ + { id: '1', title: 'first post', createdAt: 0 }, + { id: '2', title: 'second post', createdAt: 1 }, + ], + }; + const postLiveInputs: unknown[] = []; + const createContext = jest.fn(() => ({})); + const allPosts = jest.fn(); + const postById = jest.fn(); + let wsClient: TRPCWebSocketClient = null as any; + + const t = initTRPC.create({ + errorFormatter({ shape, error }) { + return { + $test: 'formatted', + zodError: + error.cause instanceof ZodError ? error.cause.flatten() : null, + ...shape, + }; + }, + }); + + let count = 0; + const appRouter = t.router({ + count: t.procedure.input(z.string()).query(({ input }) => { + return `${input}:${count++}`; + }), + allPosts: t.procedure.query(({}) => { + allPosts(); + return db.posts; + }), + postById: t.procedure.input(z.string()).query(({ input }) => { + postById(input); + const post = db.posts.find((p) => p.id === input); + if (!post) { + throw new TRPCError({ code: 'NOT_FOUND' }); + } + return post; + }), + paginatedPosts: t.procedure + .input( + z.object({ + limit: z.number().min(1).max(100).nullish(), + cursor: z.number().nullish(), + }), + ) + .query(({ input }) => { + const items: typeof db.posts = []; + const limit = input.limit ?? 50; + const { cursor } = input; + let nextCursor: typeof cursor = null; + for (let index = 0; index < db.posts.length; index++) { + const element = db.posts[index]!; + if (cursor != null && element!.createdAt < cursor) { + continue; + } + items.push(element); + if (items.length >= limit) { + break; + } + } + const last = items[items.length - 1]; + const nextIndex = db.posts.findIndex((item) => item === last) + 1; + if (db.posts[nextIndex]) { + nextCursor = db.posts[nextIndex]!.createdAt; + } + return { + items, + nextCursor, + }; + }), + + addPost: t.procedure + .input( + z.object({ + title: z.string(), + }), + ) + .mutation(({ input }) => { + db.posts.push({ + id: `${Math.random()}`, + createdAt: Date.now(), + title: input.title, + }); + }), + + deletePosts: t.procedure + .input(z.array(z.string()).nullish()) + .mutation(({ input }) => { + if (input) { + db.posts = db.posts.filter((p) => !input.includes(p.id)); + } else { + db.posts = []; + } + }), + + PING: t.procedure.mutation(({}) => { + return 'PONG' as const; + }), + + newPosts: t.procedure.input(z.number()).subscription(({ input }) => { + return subscriptionPullFactory({ + intervalMs: 1, + pull(emit) { + db.posts.filter((p) => p.createdAt > input).forEach(emit.next); + }, + }); + }), + + postsLive: t.procedure + .input( + z.object({ + cursor: z.string().nullable(), + }), + ) + .subscription(({ input }) => { + const { cursor } = input; + postLiveInputs.push(input); + + return subscriptionPullFactory>({ + intervalMs: 10, + pull(emit) { + const newCursor = hash(db.posts); + if (newCursor !== cursor) { + emit.next({ data: db.posts, cursor: newCursor }); + } + }, + }); + }), + }); + + const linkSpy = { + up: jest.fn(), + down: jest.fn(), + }; + const { client, trpcClientOptions, close } = routerToServerAndClientNew( + appRouter, + { + server: { + createContext, + batching: { + enabled: true, + }, + }, + client({ httpUrl, wssUrl }) { + wsClient = createWSClient({ + url: wssUrl, + }); + return { + links: [ + () => + ({ op, next }) => { + return observable((observer) => { + linkSpy.up(op); + const subscription = next(op).subscribe({ + next(result) { + linkSpy.down(result); + observer.next(result); + }, + error(result) { + linkSpy.down(result); + observer.error(result); + }, + complete() { + linkSpy.down('COMPLETE'); + observer.complete(); + }, + }); + return subscription; + }); + }, + splitLink({ + condition(op) { + return op.type === 'subscription'; + }, + true: wsLink({ + client: wsClient, + }), + false: httpBatchLink({ + url: httpUrl, + }), + }), + ], + }; + }, + }, + ); + + trpcClientOptions.queryClientConfig = createQueryClientConfig( + trpcClientOptions.queryClientConfig, + ); + const queryClient = createQueryClient(trpcClientOptions.queryClientConfig); + const trpc = createTRPCReact(); + + function App(props: { children: ReactNode }) { + return ( + + + {props.children} + + + ); + } + return { + App, + appRouter, + trpc, + close, + db, + client, + trpcClientOptions, + postLiveInputs, + resolvers: { + postById, + allPosts, + }, + queryClient, + createContext, + linkSpy, + }; +} diff --git a/packages/tests/server/react/dehydrate.test.tsx b/packages/tests/server/react/dehydrate.test.tsx new file mode 100644 index 00000000000..cff64b4cd4a --- /dev/null +++ b/packages/tests/server/react/dehydrate.test.tsx @@ -0,0 +1,46 @@ +/* eslint-disable @typescript-eslint/no-empty-function */ +import { createAppRouter } from './__testHelpers'; +import '@testing-library/jest-dom'; +import { createProxySSGHelpers } from '@trpc/react-query/src/ssg'; + +let factory: ReturnType; +beforeEach(() => { + factory = createAppRouter(); +}); +afterEach(() => { + factory.close(); +}); + +test('dehydrate', async () => { + const { db, appRouter } = factory; + const ssg = createProxySSGHelpers({ router: appRouter, ctx: {} }); + + await ssg.allPosts.prefetch(); + await ssg.postById.prefetch('1'); + + const dehydrated = ssg.dehydrate().queries; + expect(dehydrated).toHaveLength(2); + + const [cache, cache2] = dehydrated; + expect(cache!.queryHash).toMatchInlineSnapshot( + `"[[\\"allPosts\\"],{\\"type\\":\\"query\\"}]"`, + ); + expect(cache!.queryKey).toMatchInlineSnapshot(` + Array [ + Array [ + "allPosts", + ], + Object { + "type": "query", + }, + ] + `); + expect(cache!.state.data).toEqual(db.posts); + expect(cache2!.state.data).toMatchInlineSnapshot(` + Object { + "createdAt": 0, + "id": "1", + "title": "first post", + } + `); +}); diff --git a/packages/tests/server/react/formatError.test.tsx b/packages/tests/server/react/formatError.test.tsx new file mode 100644 index 00000000000..c60d69cb963 --- /dev/null +++ b/packages/tests/server/react/formatError.test.tsx @@ -0,0 +1,66 @@ +/* eslint-disable @typescript-eslint/no-empty-function */ +import { createQueryClient } from '../__queryClient'; +import { createAppRouter } from './__testHelpers'; +import { QueryClientProvider } from '@tanstack/react-query'; +import '@testing-library/jest-dom'; +import { render, waitFor } from '@testing-library/react'; +import { DefaultErrorShape } from '@trpc/server/src/error/formatter'; +import { expectTypeOf } from 'expect-type'; +import React, { useEffect, useState } from 'react'; + +let factory: ReturnType; +beforeEach(() => { + factory = createAppRouter(); +}); +afterEach(() => { + factory.close(); +}); + +test('react types test', async () => { + const { trpc, client } = factory; + function MyComponent() { + const mutation = trpc.addPost.useMutation(); + + useEffect(() => { + mutation.mutate({ title: 123 as any }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + if (mutation.error && mutation.error && mutation.error.shape) { + expectTypeOf(mutation.error.shape).toMatchTypeOf< + DefaultErrorShape & { + $test: string; + } + >(); + expectTypeOf(mutation.error.shape).toMatchTypeOf< + DefaultErrorShape & { + $test: string; + } + >(); + return ( +
+          {JSON.stringify(mutation.error.shape.zodError, null, 2)}
+        
+ ); + } + return <>; + } + function App() { + const [queryClient] = useState(() => createQueryClient()); + return ( + + + + + + ); + } + + const utils = render(); + await waitFor(() => { + expect(utils.container).toHaveTextContent('fieldErrors'); + expect(utils.getByTestId('err').innerText).toMatchInlineSnapshot( + `undefined`, + ); + }); +}); diff --git a/packages/tests/server/react/infiniteQuery.test.tsx b/packages/tests/server/react/infiniteQuery.test.tsx new file mode 100644 index 00000000000..1886bc0ce50 --- /dev/null +++ b/packages/tests/server/react/infiniteQuery.test.tsx @@ -0,0 +1,397 @@ +/* eslint-disable @typescript-eslint/no-empty-function */ +import { createQueryClient } from '../__queryClient'; +import { Post, createAppRouter } from './__testHelpers'; +import { QueryClientProvider } from '@tanstack/react-query'; +import '@testing-library/jest-dom'; +import { render, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { createProxySSGHelpers } from '@trpc/react-query/src/ssg'; +import { expectTypeOf } from 'expect-type'; +import React, { Fragment, useState } from 'react'; + +let factory: ReturnType; +beforeEach(() => { + factory = createAppRouter(); +}); +afterEach(() => { + factory.close(); +}); + +describe('Infinite Query', () => { + test('useInfiniteQuery()', async () => { + const { trpc, client } = factory; + + function MyComponent() { + const q = trpc.paginatedPosts.useInfiniteQuery( + { + limit: 1, + }, + { + getNextPageParam: (lastPage) => lastPage.nextCursor, + }, + ); + + expectTypeOf(q.data?.pages[0]!.items).toMatchTypeOf(); + + return q.status === 'loading' ? ( +

Loading...

+ ) : q.status === 'error' ? ( +

Error: {q.error.message}

+ ) : ( + <> + {q.data?.pages.map((group, i) => ( + + {group.items.map((msg) => ( + +
{msg.title}
+
+ ))} +
+ ))} +
+ +
+
+ {q.isFetching && !q.isFetchingNextPage ? 'Fetching...' : null} +
+ + ); + } + function App() { + const [queryClient] = useState(() => createQueryClient()); + return ( + + + + + + ); + } + + const utils = render(); + await waitFor(() => { + expect(utils.container).toHaveTextContent('first post'); + }); + await waitFor(() => { + expect(utils.container).toHaveTextContent('first post'); + expect(utils.container).not.toHaveTextContent('second post'); + expect(utils.container).toHaveTextContent('Load More'); + }); + userEvent.click(utils.getByTestId('loadMore')); + await waitFor(() => { + expect(utils.container).toHaveTextContent('Loading more...'); + }); + await waitFor(() => { + expect(utils.container).toHaveTextContent('first post'); + expect(utils.container).toHaveTextContent('second post'); + expect(utils.container).toHaveTextContent('Nothing more to load'); + }); + + expect(utils.container).toMatchInlineSnapshot(` +
+
+ first post +
+
+ second post +
+
+ +
+
+
+ `); + }); + + test('useInfiniteQuery and prefetchInfiniteQuery', async () => { + const { trpc, client } = factory; + + function MyComponent() { + const trpcContext = trpc.useContext(); + const q = trpc.paginatedPosts.useInfiniteQuery( + { + limit: 1, + }, + { + getNextPageParam: (lastPage) => lastPage.nextCursor, + }, + ); + + expectTypeOf(q.data?.pages[0]?.items).toMatchTypeOf(); + + return q.status === 'loading' ? ( +

Loading...

+ ) : q.status === 'error' ? ( +

Error: {q.error.message}

+ ) : ( + <> + {q.data?.pages.map((group, i) => ( + + {group.items.map((msg) => ( + +
{msg.title}
+
+ ))} +
+ ))} +
+ +
+
+ +
+
+ {q.isFetching && !q.isFetchingNextPage ? 'Fetching...' : null} +
+ + ); + } + function App() { + const [queryClient] = useState(() => createQueryClient()); + return ( + + + + + + ); + } + + const utils = render(); + await waitFor(() => { + expect(utils.container).toHaveTextContent('first post'); + }); + await waitFor(() => { + expect(utils.container).toHaveTextContent('first post'); + expect(utils.container).not.toHaveTextContent('second post'); + expect(utils.container).toHaveTextContent('Load More'); + }); + userEvent.click(utils.getByTestId('loadMore')); + await waitFor(() => { + expect(utils.container).toHaveTextContent('Loading more...'); + }); + await waitFor(() => { + expect(utils.container).toHaveTextContent('first post'); + expect(utils.container).toHaveTextContent('second post'); + expect(utils.container).toHaveTextContent('Nothing more to load'); + }); + + expect(utils.container).toMatchInlineSnapshot(` +
+
+ first post +
+
+ second post +
+
+ +
+
+ +
+
+
+ `); + + userEvent.click(utils.getByTestId('prefetch')); + await waitFor(() => { + expect(utils.container).toHaveTextContent('Fetching...'); + }); + await waitFor(() => { + expect(utils.container).not.toHaveTextContent('Fetching...'); + }); + + // It should correctly fetch both pages + expect(utils.container).toHaveTextContent('first post'); + expect(utils.container).toHaveTextContent('second post'); + }); + + test('useInfiniteQuery and fetchInfiniteQuery', async () => { + const { trpc, client } = factory; + + function MyComponent() { + const trpcContext = trpc.useContext(); + const q = trpc.paginatedPosts.useInfiniteQuery( + { + limit: 1, + }, + { + getNextPageParam: (lastPage) => lastPage.nextCursor, + }, + ); + expectTypeOf(q.data?.pages[0]?.items).toMatchTypeOf(); + + return q.status === 'loading' ? ( +

Loading...

+ ) : q.status === 'error' ? ( +

Error: {q.error.message}

+ ) : ( + <> + {q.data?.pages.map((group, i) => ( + + {group.items.map((msg) => ( + +
{msg.title}
+
+ ))} +
+ ))} +
+ +
+
+ +
+
+ {q.isFetching && !q.isFetchingNextPage ? 'Fetching...' : null} +
+ + ); + } + function App() { + const [queryClient] = useState(() => createQueryClient()); + return ( + + + + + + ); + } + + const utils = render(); + await waitFor(() => { + expect(utils.container).toHaveTextContent('first post'); + }); + await waitFor(() => { + expect(utils.container).toHaveTextContent('first post'); + expect(utils.container).not.toHaveTextContent('second post'); + expect(utils.container).toHaveTextContent('Load More'); + }); + userEvent.click(utils.getByTestId('loadMore')); + await waitFor(() => { + expect(utils.container).toHaveTextContent('Loading more...'); + }); + await waitFor(() => { + expect(utils.container).toHaveTextContent('first post'); + expect(utils.container).toHaveTextContent('second post'); + expect(utils.container).toHaveTextContent('Nothing more to load'); + }); + + expect(utils.container).toMatchInlineSnapshot(` +
+
+ first post +
+
+ second post +
+
+ +
+
+ +
+
+
+ `); + + userEvent.click(utils.getByTestId('fetch')); + await waitFor(() => { + expect(utils.container).toHaveTextContent('Fetching...'); + }); + await waitFor(() => { + expect(utils.container).not.toHaveTextContent('Fetching...'); + }); + + // It should correctly fetch both pages + expect(utils.container).toHaveTextContent('first post'); + expect(utils.container).toHaveTextContent('second post'); + }); + + test('prefetchInfiniteQuery()', async () => { + const { appRouter } = factory; + const ssg = createProxySSGHelpers({ router: appRouter, ctx: {} }); + + { + await ssg.paginatedPosts.prefetchInfinite({ limit: 1 }); + const data = JSON.stringify(ssg.dehydrate()); + expect(data).toContain('first post'); + expect(data).not.toContain('second post'); + } + { + await ssg.paginatedPosts.fetchInfinite({ limit: 2 }); + const data = JSON.stringify(ssg.dehydrate()); + expect(data).toContain('first post'); + expect(data).toContain('second post'); + } + }); +}); diff --git a/packages/tests/server/react/invalidateQueries.test.tsx b/packages/tests/server/react/invalidateQueries.test.tsx new file mode 100644 index 00000000000..2e89af23c07 --- /dev/null +++ b/packages/tests/server/react/invalidateQueries.test.tsx @@ -0,0 +1,234 @@ +/* eslint-disable @typescript-eslint/no-empty-function */ +import { createQueryClient } from '../__queryClient'; +import { createAppRouter } from './__testHelpers'; +import { QueryClientProvider, useQueryClient } from '@tanstack/react-query'; +import '@testing-library/jest-dom'; +import { render, waitFor } from '@testing-library/react'; +import React, { useState } from 'react'; + +let factory: ReturnType; +beforeEach(() => { + factory = createAppRouter(); +}); +afterEach(() => { + factory.close(); +}); + +describe('invalidateQueries()', () => { + test('queryClient.invalidateQueries()', async () => { + const { trpc, resolvers, client } = factory; + function MyComponent() { + const allPostsQuery = trpc.allPosts.useQuery(undefined, { + staleTime: Infinity, + }); + const postByIdQuery = trpc.postById.useQuery('1', { + staleTime: Infinity, + }); + const queryClient = useQueryClient(); + + return ( + <> +
+            allPostsQuery:{allPostsQuery.status} allPostsQuery:
+            {allPostsQuery.isStale ? 'stale' : 'not-stale'}{' '}
+          
+
+            postByIdQuery:{postByIdQuery.status} postByIdQuery:
+            {postByIdQuery.isStale ? 'stale' : 'not-stale'}
+          
+