chore: cleanup trpc related code

This commit is contained in:
Nicolas Meienberger 2023-10-06 08:55:31 +02:00 committed by Nicolas Meienberger
parent 34006c680b
commit 36675a67d4
24 changed files with 20 additions and 844 deletions

View File

@ -5,11 +5,11 @@ import type { AppStatus } from '@/server/db/schema';
import { useTranslations } from 'next-intl';
import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuTrigger } from '@/components/ui/DropdownMenu';
import { AppWithInfo } from '@/client/core/types';
import { Button } from '@/components/ui/Button';
import type { AppService } from '@/server/services/apps/apps.service';
interface IProps {
app: AppWithInfo;
app: Awaited<ReturnType<AppService['getApp']>>;
status?: AppStatus;
updateAvailable: boolean;
localDomain?: string;

View File

@ -3,7 +3,6 @@
import React from 'react';
import { toast } from 'react-hot-toast';
import { useTranslations } from 'next-intl';
import { AppRouterOutput } from '@/server/routers/app/app.router';
import { useDisclosure } from '@/client/hooks/useDisclosure';
import { useAction } from 'next-safe-action/hook';
import { installAppAction } from '@/actions/app-actions/install-app-action';
@ -16,6 +15,7 @@ import { AppLogo } from '@/components/AppLogo';
import { AppStatus } from '@/components/AppStatus';
import { AppStatus as AppStatusEnum } from '@/server/db/schema';
import { castAppConfig } from '@/lib/helpers/castAppConfig';
import { AppService } from '@/server/services/apps/apps.service';
import { InstallModal } from '../InstallModal';
import { StopModal } from '../StopModal';
import { UninstallModal } from '../UninstallModal';
@ -26,7 +26,7 @@ import { AppDetailsTabs } from '../AppDetailsTabs';
import { FormValues } from '../InstallForm';
interface IProps {
app: AppRouterOutput['getApp'];
app: Awaited<ReturnType<AppService['getApp']>>;
localDomain?: string;
}
type OpenType = 'local' | 'domain' | 'local_domain';

View File

@ -1,7 +1,6 @@
import { AppServiceClass } from '@/server/services/apps/apps.service';
import { db } from '@/server/db';
import React from 'react';
import { AppRouterOutput } from '@/server/routers/app/app.router';
import { Metadata } from 'next';
import { getTranslatorFromCookie } from '@/lib/get-translator';
import { AppTile } from './components/AppTile';
@ -19,7 +18,7 @@ export default async function Page() {
const appsService = new AppServiceClass(db);
const installedApps = await appsService.installedApps();
const renderApp = (app: AppRouterOutput['installedApps'][number]) => {
const renderApp = (app: (typeof installedApps)[number]) => {
const updateAvailable = Number(app.version) < Number(app.latestVersion);
if (app.info?.available) return <AppTile key={app.id} app={app.info} status={app.status} updateAvailable={updateAvailable} />;

View File

@ -1,4 +0,0 @@
import * as Router from '../../server/routers/_app';
export type App = Omit<Router.RouterOutput['app']['getApp'], 'info'>;
export type AppWithInfo = Router.RouterOutput['app']['getApp'];

View File

@ -1,109 +0,0 @@
import { getTRPCMock } from '@/client/mocks/getTrpcMock';
import { server } from '@/client/mocks/server';
import { deleteCookie, setCookie, getCookie } from 'cookies-next';
import { renderHook, waitFor } from '../../../../tests/test-utils';
import { useLocale } from '../useLocale';
beforeEach(() => {
deleteCookie('tipi-locale');
});
describe('test: useLocale()', () => {
describe('test: locale', () => {
it('should return users locale if logged in', async () => {
// arrange
const locale = 'fr-FR';
// @ts-expect-error - we're mocking the trpc context
server.use(getTRPCMock({ path: ['auth', 'me'], response: { locale } }));
// act
const { result } = renderHook(() => useLocale());
// assert
await waitFor(() => {
expect(result.current.locale).toEqual(locale);
});
});
it('should return cookie locale if not logged in', async () => {
// arrange
const locale = 'fr-FR';
setCookie('tipi-locale', locale);
server.use(getTRPCMock({ path: ['auth', 'me'], response: null }));
// act
const { result } = renderHook(() => useLocale());
// assert
await waitFor(() => {
expect(result.current.locale).toEqual(locale);
});
});
it('should return browser locale if not logged in and no cookie', async () => {
// arrange
const locale = 'fr-FR';
jest.spyOn(window.navigator, 'language', 'get').mockReturnValueOnce(locale);
server.use(getTRPCMock({ path: ['auth', 'me'], response: null }));
// act
const { result } = renderHook(() => useLocale());
// assert
await waitFor(() => {
expect(result.current.locale).toEqual(locale);
});
});
it('should default to english if no locale is found', async () => {
// arrange
server.use(getTRPCMock({ path: ['auth', 'me'], response: null }));
// @ts-expect-error - we're mocking window.navigator
jest.spyOn(window.navigator, 'language', 'get').mockReturnValueOnce(undefined);
// act
const { result } = renderHook(() => useLocale());
// assert
await waitFor(() => {
expect(result.current.locale).toEqual('en-US');
});
});
});
describe('test: changeLocale()', () => {
it('should set the locale in the cookie', async () => {
// arrange
const locale = 'fr-FR';
const { result } = renderHook(() => useLocale());
// act
result.current.changeLocale(locale);
// assert
await waitFor(() => {
expect(getCookie('tipi-locale')).toEqual('fr-FR');
});
});
it('should update the locale in the user profile when logged in', async () => {
// arrange
const locale = 'en';
// @ts-expect-error - we're mocking the trpc context
server.use(getTRPCMock({ path: ['auth', 'me'], response: { locale: 'fr-FR' } }));
server.use(getTRPCMock({ path: ['auth', 'changeLocale'], type: 'mutation', response: true }));
const { result } = renderHook(() => useLocale());
await waitFor(() => {
expect(result.current.locale).toEqual('fr-FR');
});
// act
result.current.changeLocale(locale);
// assert
await waitFor(() => {
expect(getCookie('tipi-locale')).toEqual(locale);
});
});
});
});

View File

@ -1,30 +0,0 @@
import { setCookie, getCookie } from 'cookies-next';
import { useRouter } from 'next/router';
import { trpc } from '@/utils/trpc';
import { Locale, getLocaleFromString } from '@/shared/internationalization/locales';
export const useLocale = () => {
const router = useRouter();
const me = trpc.auth.me.useQuery();
const changeUserLocale = trpc.auth.changeLocale.useMutation();
const browserLocale = typeof window !== 'undefined' ? window.navigator.language : undefined;
const cookieLocale = getCookie('tipi-locale');
const locale = String(me.data?.locale || cookieLocale || browserLocale || 'en');
const ctx = trpc.useContext();
const changeLocale = async (l: Locale) => {
if (me.data) {
await changeUserLocale.mutateAsync({ locale: l });
await ctx.invalidate();
}
setCookie('tipi-locale', l, {
expires: new Date(Date.now() + 1000 * 60 * 60 * 24 * 365),
});
router.reload();
};
return { locale: getLocaleFromString(locale), changeLocale };
};

View File

@ -1,64 +0,0 @@
import { faker } from '@faker-js/faker';
import type { AppStatus } from '@/server/db/schema';
import { AppInfo, AppCategory, APP_CATEGORIES } from '@runtipi/shared';
import { App, AppWithInfo } from '../../core/types';
const randomCategory = (): AppCategory[] => {
const categories = Object.values(APP_CATEGORIES);
const randomIndex = faker.number.int({ min: 0, max: categories.length - 1 });
return [categories[randomIndex] as AppCategory];
};
const createApp = (overrides?: Partial<AppInfo>): AppInfo => {
const name = faker.lorem.word();
return {
id: name.toLowerCase(),
name,
description: faker.lorem.words(),
author: faker.lorem.word(),
available: true,
categories: randomCategory(),
form_fields: [],
port: faker.number.int({ min: 1000, max: 9999 }),
short_desc: faker.lorem.words(),
tipi_version: 1,
version: faker.system.semver(),
source: faker.internet.url(),
https: false,
no_gui: false,
exposable: true,
url_suffix: '',
force_expose: false,
generate_vapid_keys: false,
...overrides,
};
};
type CreateAppEntityParams = {
overrides?: Omit<Partial<App>, 'info'>;
overridesInfo?: Partial<AppInfo>;
status?: AppStatus;
};
export const createAppEntity = (params: CreateAppEntityParams): AppWithInfo => {
const { overrides, overridesInfo, status = 'running' } = params;
const id = faker.lorem.word().toLowerCase();
const app = createApp({ id, ...overridesInfo });
return {
id,
status,
info: app,
config: {},
exposed: false,
domain: null,
version: 1,
lastOpened: faker.date.past().toISOString(),
numOpened: 0,
createdAt: faker.date.past().toISOString(),
updatedAt: faker.date.past().toISOString(),
latestVersion: 1,
latestDockerVersion: '1.0.0',
...overrides,
};
};

View File

@ -1,91 +0,0 @@
import { rest } from 'msw';
import SuperJSON from 'superjson';
import type { RouterInput, RouterOutput } from '../../server/routers/_app';
type RpcSuccessResponse<Data> = {
id: null;
result: { type: 'data'; data: Data };
};
type RpcErrorResponse = {
error: {
json: {
message: string;
code: number;
data: {
code: string;
httpStatus: number;
stack: string;
path: string; // TQuery
zodError?: Record<string, string>;
tError: {
message: string;
};
};
};
};
};
const jsonRpcSuccessResponse = (data: unknown): RpcSuccessResponse<unknown> => {
const response = SuperJSON.serialize(data);
return {
id: null,
result: { type: 'data', data: response },
};
};
const jsonRpcErrorResponse = (path: string, status: number, message: string, zodError?: Record<string, string>): RpcErrorResponse => ({
error: {
json: {
message,
code: -32600,
data: {
code: 'INTERNAL_SERVER_ERROR',
httpStatus: status,
stack: 'Error: Internal Server Error',
path,
zodError,
tError: {
message,
},
},
},
},
});
export const getTRPCMock = <
K1 extends keyof RouterInput,
K2 extends keyof RouterInput[K1], // object itself
O extends RouterOutput[K1][K2], // all its keys
>(endpoint: {
path: [K1, K2];
response: O;
type?: 'query' | 'mutation';
delay?: number;
}) => {
const fn = endpoint.type === 'mutation' ? rest.post : rest.get;
const route = `http://localhost:3000/api/trpc/${endpoint.path[0]}.${endpoint.path[1] as string}`;
return fn(route, (_, res, ctx) => res(ctx.delay(endpoint.delay), ctx.json(jsonRpcSuccessResponse(endpoint.response))));
};
export const getTRPCMockError = <
K1 extends keyof RouterInput,
K2 extends keyof RouterInput[K1], // object itself
>(endpoint: {
path: [K1, K2];
type?: 'query' | 'mutation';
status?: number;
message?: string;
zodError?: Record<string, string>;
}) => {
const fn = endpoint.type === 'mutation' ? rest.post : rest.get;
const route = `http://localhost:3000/api/trpc/${endpoint.path[0]}.${endpoint.path[1] as string}`;
return fn(route, (_, res, ctx) =>
res(ctx.delay(), ctx.json(jsonRpcErrorResponse(`${endpoint.path[0]}.${endpoint.path[1] as string}`, endpoint.status ?? 500, endpoint.message ?? 'Internal Server Error', endpoint.zodError))),
);
};

View File

@ -1,56 +1 @@
import { faker } from '@faker-js/faker';
import { createAppEntity } from './fixtures/app.fixtures';
import { getTRPCMock } from './getTrpcMock';
import { createAppConfig } from '../../server/tests/apps.factory';
export const handlers = [
getTRPCMock({
path: ['system', 'getVersion'],
type: 'query',
response: { current: '1.0.0', latest: '1.0.0', body: 'hello' },
}),
getTRPCMock({
path: ['system', 'restart'],
type: 'mutation',
response: true,
delay: 100,
}),
getTRPCMock({
path: ['system', 'getSettings'],
type: 'query',
response: { internalIp: 'localhost', dnsIp: '1.1.1.1', appsRepoUrl: 'https://test.com/test', domain: 'tipi.localhost', localDomain: 'tipi.lan' },
}),
getTRPCMock({
path: ['system', 'updateSettings'],
type: 'mutation',
response: undefined,
}),
// Auth
getTRPCMock({
path: ['auth', 'me'],
type: 'query',
response: {
totpEnabled: false,
id: faker.number.int(),
username: faker.internet.userName(),
locale: 'en',
operator: true,
},
}),
// App
getTRPCMock({
path: ['app', 'getApp'],
type: 'query',
response: createAppEntity({ status: 'running' }),
}),
getTRPCMock({
path: ['app', 'installedApps'],
type: 'query',
response: [createAppEntity({ status: 'running' }), createAppEntity({ status: 'stopped' })],
}),
getTRPCMock({
path: ['app', 'listApps'],
type: 'query',
response: { apps: [createAppConfig({}), createAppConfig({})], total: 2 },
}),
];
export const handlers = [];

View File

@ -1,96 +0,0 @@
import merge from 'lodash.merge';
import { deleteCookie, setCookie } from 'cookies-next';
import { fromPartial } from '@total-typescript/shoehorn';
import { TipiCache } from '@/server/core/TipiCache';
import { getAuthedPageProps, getMessagesPageProps } from '../page-helpers';
import englishMessages from '../../messages/en.json';
import frenchMessages from '../../messages/fr-FR.json';
const cache = new TipiCache('page-helpers.test.ts');
afterAll(async () => {
await cache.close();
});
describe('test: getAuthedPageProps()', () => {
it('should redirect to /login if there is no user id in session', async () => {
// arrange
const ctx = { req: { headers: {} } };
// act
// @ts-expect-error - we're passing in a partial context
const { redirect } = await getAuthedPageProps(ctx);
// assert
expect(redirect.destination).toBe('/login');
expect(redirect.permanent).toBe(false);
});
it('should return props if there is a user id in session', async () => {
// arrange
const ctx = { req: { headers: { 'x-session-id': '123' } } };
await cache.set('session:123', '456');
// act
// @ts-expect-error - we're passing in a partial context
const { props } = await getAuthedPageProps(ctx);
// assert
expect(props).toEqual({});
});
});
describe('test: getMessagesPageProps()', () => {
beforeEach(() => {
deleteCookie('tipi-locale');
});
it('should return correct messages if the locale is in the session', async () => {
// arrange
const ctx = { req: { session: { locale: 'fr' }, headers: {} } };
// act
// @ts-expect-error - we're passing in a partial context
const { props } = await getMessagesPageProps(ctx);
// assert
expect(props.messages).toEqual(merge(frenchMessages, englishMessages));
});
it('should return correct messages if the locale in the cookie', async () => {
// arrange
const ctx = { req: { session: {}, headers: {} } };
setCookie('tipi-locale', 'fr-FR', { req: fromPartial(ctx.req) });
// act
// @ts-expect-error - we're passing in a partial context
const { props } = await getMessagesPageProps(ctx);
// assert
expect(props.messages).toEqual(merge(frenchMessages, englishMessages));
});
it('should return correct messages if the locale is detected from the browser', async () => {
// arrange
const ctx = { req: { session: {}, headers: { 'accept-language': 'fr-FR' } } };
// act
// @ts-expect-error - we're passing in a partial context
const { props } = await getMessagesPageProps(ctx);
// assert
expect(props.messages).toEqual(merge(frenchMessages, englishMessages));
});
it('should default to english messages if the locale is not found', async () => {
// arrange
const ctx = { req: { session: {}, headers: {} } };
// act
// @ts-expect-error - we're passing in a partial context
const { props } = await getMessagesPageProps(ctx);
// assert
expect(props.messages).toEqual(englishMessages);
});
});

View File

@ -1,42 +0,0 @@
import { GetServerSideProps } from 'next';
import merge from 'lodash.merge';
import { getLocaleFromString } from '@/shared/internationalization/locales';
import { getCookie } from 'cookies-next';
import { TipiCache } from '@/server/core/TipiCache';
export const getAuthedPageProps: GetServerSideProps = async (ctx) => {
const cache = new TipiCache('getAuthedPageProps');
const sessionId = ctx.req.headers['x-session-id'];
const userId = await cache.get(`session:${sessionId}`);
await cache.close();
if (!userId) {
return {
redirect: {
destination: '/login',
permanent: false,
},
};
}
return {
props: {},
};
};
export const getMessagesPageProps: GetServerSideProps = async (ctx) => {
const cookieLocale = getCookie('tipi-locale', { req: ctx.req });
const browserLocale = ctx.req.headers['accept-language']?.split(',')[0];
const locale = getLocaleFromString(String(cookieLocale || browserLocale || 'en'));
const englishMessages = (await import(`../messages/en.json`)).default;
const messages = (await import(`../messages/${locale}.json`)).default;
const mergedMessages = merge(englishMessages, messages);
return {
props: {
messages: mergedMessages,
},
};
};

View File

@ -1,41 +0,0 @@
import { httpBatchLink, loggerLink } from '@trpc/client';
import { createTRPCNext } from '@trpc/next';
import superjson from 'superjson';
import type { AppRouter } from '../../server/routers/_app';
/**
* Get base url for the current environment
*
* @returns {string} base url
*/
function getBaseUrl() {
if (typeof window !== 'undefined') {
// browser should use relative path
return '';
}
return `http://localhost:${process.env.PORT ?? 3000}`;
}
export const trpc = createTRPCNext<AppRouter>({
config() {
return {
transformer: superjson,
links: [
loggerLink({
enabled: (opts) => process.env.NODE_ENV === 'development' || (opts.direction === 'down' && opts.result instanceof Error),
}),
httpBatchLink({
url: `${getBaseUrl()}/api/trpc`,
fetch(url, options) {
return fetch(url, {
...options,
credentials: 'include',
});
},
}),
],
};
},
ssr: false,
});

View File

@ -1,17 +1,12 @@
import React, { useEffect } from 'react';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import type { AppProps } from 'next/app';
import Head from 'next/head';
import { NextIntlProvider, createTranslator } from 'next-intl';
import '../client/styles/global.css';
import '../client/styles/global.scss';
import 'react-tooltip/dist/react-tooltip.css';
import { Toaster } from 'react-hot-toast';
import { useLocale } from '@/client/hooks/useLocale';
import { useUIStore } from '../client/state/uiStore';
import { StatusProvider } from '../client/components/hoc/StatusProvider';
import { trpc } from '../client/utils/trpc';
import { SystemStatus, useSystemStore } from '../client/state/systemStore';
/**
* Next.js App component
@ -20,18 +15,7 @@ import { SystemStatus, useSystemStore } from '../client/state/systemStore';
* @returns {JSX.Element} - JSX element
*/
function MyApp({ Component, pageProps }: AppProps) {
const { setDarkMode, setTranslator } = useUIStore();
const { setStatus, setVersion, pollStatus } = useSystemStore();
const { locale } = useLocale();
trpc.system.status.useQuery(undefined, { networkMode: 'online', refetchInterval: 2000, onSuccess: (d) => setStatus((d.status as SystemStatus) || 'RUNNING'), enabled: pollStatus });
const version = trpc.system.getVersion.useQuery(undefined, { networkMode: 'online' });
useEffect(() => {
if (version.data) {
setVersion(version.data);
}
}, [setVersion, version.data]);
const { setDarkMode } = useUIStore();
// check theme on component mount
useEffect(() => {
@ -47,28 +31,17 @@ function MyApp({ Component, pageProps }: AppProps) {
themeCheck();
}, [setDarkMode]);
useEffect(() => {
const translator = createTranslator({
messages: pageProps.messages,
locale,
});
setTranslator(translator);
}, [pageProps.messages, locale, setTranslator]);
return (
<main className="h-100">
<NextIntlProvider locale={locale} messages={pageProps.messages}>
<Head>
<title>Tipi</title>
</Head>
<StatusProvider>
<Component {...pageProps} />
</StatusProvider>
<Toaster />
</NextIntlProvider>
<ReactQueryDevtools />
<Head>
<title>Tipi</title>
</Head>
<StatusProvider>
<Component {...pageProps} />
</StatusProvider>
<Toaster />
</main>
);
}
export default trpc.withTRPC(MyApp);
export default MyApp;

View File

@ -1,9 +0,0 @@
import * as trpcNext from '@trpc/server/adapters/next';
import { createContext } from '../../../server/context';
import { mainRouter } from '../../../server/routers/_app';
// export API handler
export default trpcNext.createNextApiHandler({
router: mainRouter,
createContext,
});

View File

@ -1,46 +0,0 @@
import { inferAsyncReturnType } from '@trpc/server';
import { CreateNextContextOptions } from '@trpc/server/adapters/next';
import { TipiCache } from './core/TipiCache/TipiCache';
type CreateContextOptions = {
req: CreateNextContextOptions['req'];
res: CreateNextContextOptions['res'];
sessionId: string;
userId?: number;
};
/**
* Use this helper for:
* - testing, so we dont have to mock Next.js' req/res
* - trpc's `createSSGHelpers` where we don't have req/res
*
* @param {CreateContextOptions} opts - options
* @see https://create.t3.gg/en/usage/trpc#-servertrpccontextts
*/
const createContextInner = async (opts: CreateContextOptions) => ({
...opts,
});
/**
* This is the actual context you'll use in your router
*
* @param {CreateNextContextOptions} opts - options
*/
export const createContext = async (opts: CreateNextContextOptions) => {
const { req, res } = opts;
const sessionId = req.headers['x-session-id'] as string;
const cache = new TipiCache('createContext');
const userId = await cache.get(`session:${sessionId}`);
await cache.close();
return createContextInner({
req,
res,
sessionId,
userId: Number(userId) || undefined,
});
};
export type Context = inferAsyncReturnType<typeof createContext>;

View File

@ -1,17 +0,0 @@
import { inferRouterInputs, inferRouterOutputs } from '@trpc/server';
import { router } from '../trpc';
import { appRouter } from './app/app.router';
import { authRouter } from './auth/auth.router';
import { systemRouter } from './system/system.router';
export const mainRouter = router({
system: systemRouter,
auth: authRouter,
app: appRouter,
});
// export type definition of API
export type AppRouter = typeof mainRouter;
export type RouterInput = inferRouterInputs<AppRouter>;
export type RouterOutput = inferRouterOutputs<AppRouter>;

View File

@ -1,27 +0,0 @@
import { z } from 'zod';
import { inferRouterOutputs } from '@trpc/server';
import { db } from '@/server/db';
import { AppServiceClass } from '../../services/apps/apps.service';
import { router, protectedProcedure } from '../../trpc';
export type AppRouterOutput = inferRouterOutputs<typeof appRouter>;
const AppService = new AppServiceClass(db);
const formSchema = z.object({}).catchall(z.any());
export const appRouter = router({
getApp: protectedProcedure.input(z.object({ id: z.string() })).query(({ input }) => AppService.getApp(input.id)),
startAllApp: protectedProcedure.mutation(AppService.startAllApps),
startApp: protectedProcedure.input(z.object({ id: z.string() })).mutation(({ input }) => AppService.startApp(input.id)),
installApp: protectedProcedure
.input(z.object({ id: z.string(), form: formSchema, exposed: z.boolean().optional(), domain: z.string().optional() }))
.mutation(({ input }) => AppService.installApp(input.id, input.form, input.exposed, input.domain)),
updateAppConfig: protectedProcedure
.input(z.object({ id: z.string(), form: formSchema, exposed: z.boolean().optional(), domain: z.string().optional() }))
.mutation(({ input }) => AppService.updateAppConfig(input.id, input.form, input.exposed, input.domain)),
stopApp: protectedProcedure.input(z.object({ id: z.string() })).mutation(({ input }) => AppService.stopApp(input.id)),
uninstallApp: protectedProcedure.input(z.object({ id: z.string() })).mutation(({ input }) => AppService.uninstallApp(input.id)),
updateApp: protectedProcedure.input(z.object({ id: z.string() })).mutation(({ input }) => AppService.updateApp(input.id)),
installedApps: protectedProcedure.query(AppService.installedApps),
listApps: protectedProcedure.query(() => AppServiceClass.listApps()),
});

View File

@ -1,11 +0,0 @@
import { z } from 'zod';
import { AuthServiceClass } from '../../services/auth/auth.service';
import { router, publicProcedure, protectedProcedure } from '../../trpc';
import { db } from '../../db';
const AuthService = new AuthServiceClass(db);
export const authRouter = router({
me: publicProcedure.query(async ({ ctx }) => AuthService.me(ctx.userId)),
changeLocale: protectedProcedure.input(z.object({ locale: z.string() })).mutation(async ({ input, ctx }) => AuthService.changeLocale({ userId: Number(ctx.userId), locale: input.locale })),
});

View File

@ -1,7 +0,0 @@
import { mainRouter } from './_app';
describe('routers', () => {
it('should return a router', () => {
expect(mainRouter).toBeDefined();
});
});

View File

@ -1,16 +0,0 @@
import { inferRouterOutputs } from '@trpc/server';
import { settingsSchema } from '@runtipi/shared';
import { router, protectedProcedure, publicProcedure } from '../../trpc';
import { SystemServiceClass } from '../../services/system';
import * as TipiConfig from '../../core/TipiConfig';
export type SystemRouterOutput = inferRouterOutputs<typeof systemRouter>;
const SystemService = new SystemServiceClass();
export const systemRouter = router({
status: publicProcedure.query(SystemServiceClass.status),
getVersion: publicProcedure.query(SystemService.getVersion),
restart: protectedProcedure.mutation(SystemService.restart),
updateSettings: protectedProcedure.input(settingsSchema).mutation(({ input }) => TipiConfig.setSettings(input)),
getSettings: protectedProcedure.query(TipiConfig.getSettings),
});

View File

@ -370,3 +370,5 @@ export class AppServiceClass {
.filter(notEmpty);
};
}
export type AppService = InstanceType<typeof AppServiceClass>;

View File

@ -1,72 +0,0 @@
import { initTRPC, TRPCError } from '@trpc/server';
import superjson from 'superjson';
import { typeToFlattenedError, ZodError } from 'zod';
import { type Context } from './context';
import { AuthQueries } from './queries/auth/auth.queries';
import { db } from './db';
import { type MessageKey, TranslatedError } from './utils/errors';
const authQueries = new AuthQueries(db);
/**
* Convert ZodError to a record
*
* @param {typeToFlattenedError<string>} errors - errors
*/
function zodErrorsToRecord(errors: typeToFlattenedError<string>) {
const record: Record<string, string> = {};
Object.entries(errors.fieldErrors).forEach(([key, value]) => {
const error = value?.[0];
if (error) {
record[key] = error;
}
});
return record;
}
const t = initTRPC.context<Context>().create({
transformer: superjson,
errorFormatter({ shape, error }) {
return {
...shape,
data: {
...shape.data,
zodError: error.code === 'BAD_REQUEST' && error.cause instanceof ZodError ? zodErrorsToRecord(error.cause.flatten()) : null,
tError:
error.cause instanceof TranslatedError ? { message: error.cause.message as MessageKey, variables: error.cause.variableValues } : { message: error.message as MessageKey, variables: {} },
},
};
},
});
// Base router and procedure helpers
export const { router } = t;
/**
* Unprotected procedure
*/
export const publicProcedure = t.procedure;
/**
* Reusable middleware to ensure
* users are logged in
*/
const isAuthed = t.middleware(async ({ ctx, next }) => {
const { userId } = ctx;
if (!userId) {
throw new TRPCError({ code: 'UNAUTHORIZED', message: 'You need to be logged in to perform this action' });
}
const user = await authQueries.getUserById(userId);
if (!user) {
throw new TRPCError({ code: 'UNAUTHORIZED', message: 'You need to be logged in to perform this action' });
}
return next({ ctx });
});
/**
* Protected procedure
*/
export const protectedProcedure = t.procedure.use(isAuthed);

View File

@ -1,58 +0,0 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { createTRPCReact, httpLink, loggerLink } from '@trpc/react-query';
import React, { useState } from 'react';
import fetch from 'isomorphic-fetch';
import superjson from 'superjson';
import type { AppRouter } from '../src/server/routers/_app';
export const trpc = createTRPCReact<AppRouter>({
unstable_overrides: {
useMutation: {
async onSuccess(opts) {
await opts.originalFn();
await opts.queryClient.invalidateQueries();
},
},
},
});
export function TRPCTestClientProvider(props: { children: React.ReactNode }) {
const { children } = props;
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
}),
);
const [trpcClient] = useState(() =>
trpc.createClient({
transformer: superjson,
links: [
loggerLink({
enabled: () => false,
}),
httpLink({
url: 'http://localhost:3000/api/trpc',
fetch: async (input, init?) =>
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore - isomorphic-fetch is missing the `signal` option
fetch(input, {
...init,
}),
}),
],
}),
);
return (
<trpc.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
</trpc.Provider>
);
}

View File

@ -3,22 +3,19 @@ import { render, RenderOptions, renderHook } from '@testing-library/react';
import { Toaster } from 'react-hot-toast';
import { NextIntlProvider } from 'next-intl';
import ue from '@testing-library/user-event';
import { TRPCTestClientProvider } from './TRPCTestClientProvider';
import messages from '../src/client/messages/en.json';
const userEvent = ue.setup();
const AllTheProviders: FC<{ children: React.ReactNode }> = ({ children }) => (
<NextIntlProvider locale="en" messages={messages}>
<TRPCTestClientProvider>
{children}
<Toaster />
</TRPCTestClientProvider>
{children}
<Toaster />
</NextIntlProvider>
);
const customRender = (ui: ReactElement, options?: Omit<RenderOptions, 'wrapper'>) => render(ui, { wrapper: AllTheProviders, ...options });
const customRenderHook = (callback: () => any, options?: Omit<RenderOptions, 'wrapper'>) => renderHook(callback, { wrapper: AllTheProviders, ...options });
const customRenderHook = <Props, Result>(callback: (props: Props) => Result, options?: Omit<RenderOptions, 'wrapper'>) => renderHook(callback, { wrapper: AllTheProviders, ...options });
export * from '@testing-library/react';
export { customRender as render };