chore (ci): re-add user event tests (#3288)

### **PR Type**
Tests, Enhancement


___

### **Description**
- Reintroduce user event tests in CI

- Update SvelteKit example tests to e2e suite

- Refactor TestUserEvent class for improved testing

- Add new tests for database and backup features


___



### **Changes walkthrough** 📝
<table><thead><tr><th></th><th align="left">Relevant
files</th></tr></thead><tbody><tr><td><strong>Tests</strong></td><td><details><summary>7
files</summary><table>
<tr>
<td><strong>DateTimePicker.test.tsx</strong><dd><code>Add comprehensive
tests for DateTimePicker component</code>&nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; </dd></td>
<td><a
href="https://github.com/nhost/nhost/pull/3288/files#diff-c7076012eb33d6f60049710638b5ad19c2f310b8c250c79f1905be7e0a30b00a">+177/-0</a>&nbsp;
</td>

</tr>

<tr>
<td><strong>TimePicker.test.tsx</strong><dd><code>Update TimePicker
tests to use TestUserEvent</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </dd></td>
<td><a
href="https://github.com/nhost/nhost/pull/3288/files#diff-784f69003ebbc9e39837b920007cef14125a5fc48bb9114226820bcb2b0827b0">+6/-7</a>&nbsp;
&nbsp; &nbsp; </td>

</tr>

<tr>
<td><strong>TransferProjectDialog.test.tsx</strong><dd><code>Add tests
for TransferProjectDialog component</code>&nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </dd></td>
<td><a
href="https://github.com/nhost/nhost/pull/3288/files#diff-d4ebdb8af76a7c9e73606708718c3448445545259ad553d73b6d322408e3eb8c">+234/-0</a>&nbsp;
</td>

</tr>

<tr>
<td><strong>ImportBackupTabContent.test.tsx</strong><dd><code>Add tests
for ImportBackupTabContent component</code>&nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </dd></td>
<td><a
href="https://github.com/nhost/nhost/pull/3288/files#diff-753e5e6735a2d612b6ccc6617c053017ba591a763182fa28a8fc302731c3f347">+267/-0</a>&nbsp;
</td>

</tr>

<tr>
<td><strong>DatabasePiTRSettings.test.tsx</strong><dd><code>Add tests
for DatabasePiTRSettings component</code>&nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
</dd></td>
<td><a
href="https://github.com/nhost/nhost/pull/3288/files#diff-85d1f82a571b56469eab40dcc164fdd1e107fba79611ddd5cca7c191fe5117b4">+188/-0</a>&nbsp;
</td>

</tr>

<tr>
<td><strong>ResourcesForm.test.tsx</strong><dd><code>Update
ResourcesForm tests to use TestUserEvent</code>&nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </dd></td>
<td><a
href="https://github.com/nhost/nhost/pull/3288/files#diff-8828db70c080be6fc19f88059b08587584f1c23c9159092d6b186ca82a1943aa">+60/-55</a>&nbsp;
</td>

</tr>

<tr>
<td><strong>resourceSettingsQuery.ts</strong><dd><code>Update
resourcesAvailableQuery mock data</code>&nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; </dd></td>
<td><a
href="https://github.com/nhost/nhost/pull/3288/files#diff-49b3a2a24ead48f97ace0b90f1ecaf4d4edbdef17109e29f5101016515e5946a">+12/-0</a>&nbsp;
&nbsp; </td>

</tr>

</table></details></td></tr><tr><td><strong>Enhancement</strong></td><td><details><summary>2
files</summary><table>
<tr>
<td><strong>testUtils.tsx</strong><dd><code>Refactor TestUserEvent class
and remove utility functions</code></dd></td>
<td><a
href="https://github.com/nhost/nhost/pull/3288/files#diff-78f29250407edf853a353b48242d3cee59aa5724f38a60bb23bebdfc1ea2f9b5">+35/-18</a>&nbsp;
</td>

</tr>

<tr>
<td><strong>package.json</strong><dd><code>Update SvelteKit example test
script to e2e</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </dd></td>
<td><a
href="https://github.com/nhost/nhost/pull/3288/files#diff-6288951fff74ec246c9cc023b7b7e3e9aad31423891bc4ea25b5d84a5f5b061f">+1/-1</a>&nbsp;
&nbsp; &nbsp; </td>

</tr>

</table></details></td></tr><tr><td><strong>Documentation</strong></td><td><details><summary>1
files</summary><table>
<tr>
<td><strong>nasty-cherries-cover.md</strong><dd><code>Add changeset for
user event CI tests</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; </dd></td>
<td><a
href="https://github.com/nhost/nhost/pull/3288/files#diff-653e520d91e00e8c62155076a5acfb2a606381f63c4c87b42ac70d23e7c97a01">+6/-0</a>&nbsp;
&nbsp; &nbsp; </td>

</tr>
</table></details></td></tr><tr><td><strong>Additional
files</strong></td><td><details><summary>1 files</summary><table>
<tr>
  <td><strong>PointInTimeBackupInfo.test.tsx</strong></td>
<td><a
href="https://github.com/nhost/nhost/pull/3288/files#diff-3980415ca79bf039abb469281fff9b1dc1de0a1ef52b4044d8c6f529538b6edf">+356/-0</a>&nbsp;
</td>

</tr>
</table></details></td></tr></tr></tbody></table>

___

> <details> <summary> Need help?</summary><li>Type <code>/help how to
...</code> in the comments thread for any questions about PR-Agent
usage.</li><li>Check out the <a
href="https://qodo-merge-docs.qodo.ai/usage-guide/">documentation</a>
for more information.</li></details>
This commit is contained in:
David BM
2025-04-07 17:15:33 +02:00
committed by GitHub
parent d26b6b848d
commit 4fd176bce2
11 changed files with 1348 additions and 80 deletions

View File

@@ -0,0 +1,6 @@
---
'@nhost-examples/sveltekit': minor
'@nhost/dashboard': minor
---
chore: re-add user event ci tests, updated sveltekit example tests to e2e suite

View File

@@ -0,0 +1,177 @@
import { isTZDate } from '@/components/common/TimePicker/time-picker-utils';
import { render, screen, TestUserEvent, waitFor } from '@/tests/testUtils';
import { isBefore, startOfDay } from 'date-fns-v4';
import { useState } from 'react';
import { TZDate } from 'react-day-picker';
import { vi } from 'vitest';
import DateTimePicker, { type DateTimePickerProps } from './DateTimePicker';
vi.mock('@/utils/timezoneUtils', async () => {
const actualTimezoneUtils = await vi.importActual<any>(
'@/utils/timezoneUtils',
);
return {
...actualTimezoneUtils,
guessTimezone: () => 'Europe/Helsinki',
};
});
const earliestBackupDate = '2025-03-13T02:00:05.000Z';
function TestComponent(
props: Omit<DateTimePickerProps, 'dateTime' | 'onDateTimeChange'>,
) {
const [dateTime, setDateTime] = useState(earliestBackupDate);
function isCalendarDayDisabled(date: Date | TZDate) {
if (isTZDate(date)) {
const utcDay = new Date(date.getTime()).toISOString();
const tzDate = new TZDate(utcDay, date.timeZone);
const earliestBackupDateInTz = new TZDate(
earliestBackupDate,
date.timeZone,
);
return isBefore(startOfDay(tzDate), startOfDay(earliestBackupDateInTz));
}
return isBefore(
startOfDay(new Date(date.getTime()).toISOString()),
startOfDay(earliestBackupDate),
);
}
return (
<>
<h1 data-testid="utcDate">{dateTime}</h1>
<DateTimePicker
{...props}
isCalendarDayDisabled={isCalendarDayDisabled}
dateTime={dateTime}
onDateTimeChange={setDateTime}
/>
</>
);
}
describe('DateTimePicker', () => {
test('when the date changes datetime is emitted in utc string format', async () => {
render(<TestComponent />);
const user = new TestUserEvent();
await user.click(await screen.findByTestId('dateTimePickerTrigger'));
expect(
await screen.findByRole('button', { name: 'Select' }),
).toBeInTheDocument();
expect(await screen.getByText('March 2025')).toBeInTheDocument();
await user.click(
screen.getByRole('button', { name: 'Go to the Next Month' }),
);
expect(screen.getByText('April 2025')).toBeInTheDocument();
await user.click(await screen.getByText('13'));
const hoursInput = await screen.getByLabelText('Hours');
await user.type(hoursInput, '11');
const minutesInput = await screen.getByLabelText('Minutes');
await user.type(minutesInput, '12');
const secondsInput = await screen.getByLabelText('Seconds');
await user.type(secondsInput, '13');
user.click(await screen.getByRole('button', { name: 'Select' }));
await waitFor(async () =>
expect(
await screen.queryByRole('button', { name: 'Select' }),
).not.toBeInTheDocument(),
);
expect(screen.getByTestId('utcDate')).toHaveTextContent(
'2025-04-13T08:12:13.000Z',
);
});
test('timezone can be changed and the calendar is updated', async () => {
await waitFor(() => render(<TestComponent withTimezone />));
const user = new TestUserEvent();
await user.click(await screen.findByTestId('dateTimePickerTrigger'));
expect(await screen.findByText(/Timezone:/)).toBeInTheDocument();
expect(
await screen.findByTestId('timezoneSettingsButton'),
).toBeInTheDocument();
expect(await screen.findByText(/Timezone: /i)).toHaveTextContent(
'Timezone: UTC+02:00',
);
expect(await screen.getByText('12')).toBeDisabled();
await user.click(await screen.findByTestId('timezoneSettingsButton'));
const tzInput = await screen.findByPlaceholderText('Search timezones...');
expect(tzInput).toBeInTheDocument();
await user.type(tzInput, 'America/Chicago{ArrowDown}{Enter}');
expect(
await screen.queryByPlaceholderText('Search timezones...'),
).not.toBeInTheDocument();
expect(await screen.findByText(/Timezone: /i)).toHaveTextContent(
'Timezone: UTC-05:00',
);
const selectedDay = screen.getByText('12');
expect(selectedDay).not.toBeDisabled();
expect(await screen.getByText('11')).toBeDisabled();
const gridCell = selectedDay.closest('[role="gridcell"]');
expect(gridCell).toHaveClass('[&>button]:bg-primary');
});
test('Displays the correct time zone offset when changing the selected date from standard time (ST) to daylight saving time (DST)', async () => {
await waitFor(() => render(<TestComponent withTimezone />));
const user = new TestUserEvent();
await user.click(await screen.findByTestId('dateTimePickerTrigger'));
expect(await screen.findByText(/Timezone:/)).toBeInTheDocument();
expect(
await screen.findByTestId('timezoneSettingsButton'),
).toBeInTheDocument();
expect(await screen.findByText(/Timezone: /i)).toHaveTextContent(
'Timezone: UTC+02:00',
);
expect(await screen.getByText('March 2025')).toBeInTheDocument();
await user.click(
screen.getByRole('button', { name: 'Go to the Next Month' }),
);
expect(screen.getByText('April 2025')).toBeInTheDocument();
await user.click(await screen.getByText('18'));
expect(await screen.findByText(/Timezone: /i)).toHaveTextContent(
'Timezone: UTC+03:00',
);
await user.click(
screen.getByRole('button', { name: 'Go to the Previous Month' }),
);
expect(await screen.getByText('March 2025')).toBeInTheDocument();
await user.click(await screen.getByText('21'));
expect(await screen.findByText(/Timezone: /i)).toHaveTextContent(
'Timezone: UTC+02:00',
);
});
});

View File

@@ -1,7 +1,6 @@
import { render, screen } from '@/tests/testUtils';
import { render, screen, TestUserEvent } from '@/tests/testUtils';
import { guessTimezone } from '@/utils/timezoneUtils';
import { TZDate } from '@date-fns/tz';
import userEvent from '@testing-library/user-event';
import { parseISO } from 'date-fns';
import { format } from 'date-fns-v4';
import { useState } from 'react';
@@ -36,7 +35,7 @@ describe('TimePicker', () => {
expect(await screen.getByText(/Time:/i)).toHaveTextContent(
'Time: 03:00:05',
);
const user = userEvent.setup();
const user = new TestUserEvent();
const hoursInput = await screen.getByLabelText('Hours');
await user.type(hoursInput, '18');
expect(await screen.getByText(/Time:/i)).toHaveTextContent(
@@ -46,7 +45,7 @@ describe('TimePicker', () => {
test('only valid hours(0-23), minutes(0-59) and seconds(0-59) are allowed', async () => {
render(<TestComponent dateTime="2025-03-10T03:00:05" />);
const user = userEvent.setup();
const user = new TestUserEvent();
const hoursInput = await screen.getByLabelText('Hours');
await user.type(hoursInput, '30');
expect(await screen.getByText(/Time:/i)).toHaveTextContent(
@@ -61,7 +60,7 @@ describe('TimePicker', () => {
test('Updates only the minutes of the date object', async () => {
render(<TestComponent dateTime="2025-03-10T03:00:05" />);
const user = userEvent.setup();
const user = new TestUserEvent();
const minutesInput = await screen.getByLabelText('Minutes');
await user.type(minutesInput, '44');
expect(await screen.getByText(/Time:/i)).toHaveTextContent(
@@ -71,7 +70,7 @@ describe('TimePicker', () => {
test('Updates only the seconds of the date object', async () => {
render(<TestComponent dateTime="2025-03-10T03:00:05" />);
const user = userEvent.setup();
const user = new TestUserEvent();
const secondsInput = await screen.getByLabelText('Seconds');
await user.type(secondsInput, '11');
expect(await screen.getByText(/Time:/i)).toHaveTextContent(
@@ -84,7 +83,7 @@ describe('TimePicker', () => {
expect(await screen.getByText(/Date class:/i)).toHaveTextContent(
'Date class: TZDate',
);
const user = userEvent.setup();
const user = new TestUserEvent();
const hoursInput = await screen.getByLabelText('Hours');
await user.type(hoursInput, '18');

View File

@@ -0,0 +1,234 @@
import {
mockMatchMediaValue,
mockOrganization,
mockOrganizations,
mockOrganizationsWithNewOrg,
newOrg,
} from '@/tests/mocks';
import { getOrganization } from '@/tests/msw/mocks/graphql/getOrganizationQuery';
import { getProjectQuery } from '@/tests/msw/mocks/graphql/getProjectQuery';
import { prefetchNewAppQuery } from '@/tests/msw/mocks/graphql/prefetchNewAppQuery';
import tokenQuery from '@/tests/msw/mocks/rest/tokenQuery';
import {
createGraphqlMockResolver,
fireEvent,
mockPointerEvent,
queryClient,
render,
screen,
TestUserEvent,
waitFor,
} from '@/tests/testUtils';
import { setupServer } from 'msw/node';
import { useState } from 'react';
import { afterAll, beforeAll, vi } from 'vitest';
import TransferProjectDialog from './TransferProjectDialog';
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn().mockImplementation(mockMatchMediaValue),
});
mockPointerEvent();
const getUseRouterObject = (session_id?: string) => ({
basePath: '',
pathname: '/orgs/xyz/projects/test-project',
route: '/orgs/[orgSlug]/projects/[appSubdomain]',
asPath: '/orgs/xyz/projects/test-project',
isLocaleDomain: false,
isReady: true,
isPreview: false,
query: {
orgSlug: 'xyz',
appSubdomain: 'test-project',
session_id,
},
push: vi.fn(),
replace: vi.fn(),
reload: vi.fn(),
back: vi.fn(),
prefetch: vi.fn(),
beforePopState: vi.fn(),
events: {
on: vi.fn(),
off: vi.fn(),
emit: vi.fn(),
},
isFallback: false,
});
const mocks = vi.hoisted(() => ({
useRouter: vi.fn(),
useOrgs: vi.fn(),
}));
vi.mock('@/features/orgs/projects/hooks/useOrgs', async () => {
const actualUseOrgs = await vi.importActual<any>(
'@/features/orgs/projects/hooks/useOrgs',
);
return {
...actualUseOrgs,
useOrgs: mocks.useOrgs,
};
});
const postOrganizationRequestResolver = createGraphqlMockResolver(
'postOrganizationRequest',
'mutation',
);
vi.mock('next/router', () => ({
useRouter: mocks.useRouter,
}));
export function DialogWrapper({
defaultOpen = true,
}: {
defaultOpen?: boolean;
}) {
const [open, setOpen] = useState(defaultOpen);
return <TransferProjectDialog open={open} setOpen={setOpen} />;
}
const server = setupServer(tokenQuery);
beforeAll(() => {
process.env.NEXT_PUBLIC_NHOST_PLATFORM = 'true';
process.env.NEXT_PUBLIC_ENV = 'production';
server.listen();
});
afterEach(() => {
queryClient.clear();
mocks.useRouter.mockRestore();
});
afterAll(() => {
server.close();
vi.restoreAllMocks();
});
test('opens create org dialog when selecting "create new org" and closes transfer dialog', async () => {
mocks.useRouter.mockImplementation(() => getUseRouterObject());
const user = new TestUserEvent();
server.use(getProjectQuery);
server.use(getOrganization);
mocks.useOrgs.mockImplementation(() => ({
orgs: mockOrganizations,
currentOrg: mockOrganization,
loading: false,
refetch: vi.fn(),
}));
server.use(prefetchNewAppQuery);
render(<DialogWrapper />);
const organizationCombobox = await screen.findByRole('combobox', {
name: /Organization/i,
});
expect(organizationCombobox).toBeInTheDocument();
await user.click(organizationCombobox);
const newOrgOption = await screen.findByRole('option', {
name: 'New Organization',
});
await user.click(newOrgOption);
expect(organizationCombobox).toHaveTextContent('New Organization');
const submitButton = await screen.findByText('Continue');
expect(submitButton).toHaveTextContent('Continue');
fireEvent(
submitButton,
new MouseEvent('click', {
bubbles: true,
cancelable: true,
}),
);
await waitFor(() => {
expect(submitButton).not.toBeInTheDocument();
});
const newOrgTitle = await screen.findByText('New Organization');
expect(newOrgTitle).toBeInTheDocument();
const closeButton = await screen.findByText('Close');
fireEvent(
closeButton,
new MouseEvent('click', {
bubbles: true,
cancelable: true,
}),
);
await waitFor(() => {
expect(newOrgTitle).not.toBeInTheDocument();
});
const submitButtonAfterClosingNewOrgDialog =
await screen.findByText('Continue');
await waitFor(() => {
expect(submitButtonAfterClosingNewOrgDialog).toHaveTextContent('Continue');
});
});
test(`transfer dialog opens automatically when there is a session_id and selects the ${newOrg.name} from the dropdown`, async () => {
mocks.useRouter.mockImplementation(() => getUseRouterObject('session_id'));
server.use(getProjectQuery);
server.use(getOrganization);
mocks.useOrgs.mockImplementation(() => ({
orgs: mockOrganizations,
currentOrg: mockOrganization,
loading: false,
refetch: vi.fn(),
}));
server.use(prefetchNewAppQuery);
server.use(postOrganizationRequestResolver.handler);
render(<DialogWrapper defaultOpen={false} />);
const processingNewOrgText = await screen.findByText(
'Processing new organization request',
);
expect(processingNewOrgText).toBeInTheDocument();
const closeButton = await screen.findByText('Close');
fireEvent(
closeButton,
new MouseEvent('click', {
bubbles: true,
cancelable: true,
}),
);
await waitFor(() => {});
expect(closeButton).toBeInTheDocument();
postOrganizationRequestResolver.resolve({
billingPostOrganizationRequest: {
Status: 'COMPLETED',
Slug: newOrg.slug,
ClientSecret: null,
__typename: 'PostOrganizationRequestResponse',
},
});
mocks.useOrgs.mockImplementation(() => ({
orgs: mockOrganizationsWithNewOrg,
currentOrg: mockOrganization,
loading: false,
refetch: vi.fn(),
}));
const organizationCombobox = await screen.findByRole('combobox', {
name: /Organization/i,
});
expect(organizationCombobox).toHaveTextContent(newOrg.name);
const submitButton = await screen.findByText('Transfer');
expect(submitButton).not.toBeDisabled();
});

View File

@@ -0,0 +1,267 @@
import {
fetchPiTRBaseBackups,
mockApplication,
mockMatchMediaValue,
} from '@/tests/mocks';
import tokenQuery from '@/tests/msw/mocks/rest/tokenQuery';
import {
mockPointerEvent,
render,
screen,
TestUserEvent,
waitFor,
} from '@/tests/testUtils';
import { setupServer } from 'msw/node';
import { vi } from 'vitest';
import { Tabs } from '@/components/ui/v3/tabs';
import { getOrganization } from '@/tests/msw/mocks/graphql/getOrganizationQuery';
import {
getPiTRNotEnabledPostgresSettings,
getPostgresSettings,
} from '@/tests/msw/mocks/graphql/getPostgresSettings';
import {
getEmptyProjectsQuery,
getProjectsQuery,
} from '@/tests/msw/mocks/graphql/getProjectsQuery';
import ImportBackupContent from './ImportBackupTabContent';
function TestComponent() {
return (
<Tabs value="importBackup">
<ImportBackupContent />
</Tabs>
);
}
mockPointerEvent();
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn().mockImplementation(mockMatchMediaValue),
});
const server = setupServer(tokenQuery);
const mocks = vi.hoisted(() => ({
useGetPiTrBaseBackupsLazyQuery: vi.fn(),
fetchPiTRBaseBackups: vi.fn(),
restoreApplicationDatabase: vi.fn(),
}));
vi.mock('@/utils/__generated__/graphql', async () => {
const actual = await vi.importActual<any>('@/utils/__generated__/graphql');
return {
...actual,
useGetPiTrBaseBackupsLazyQuery: mocks.useGetPiTrBaseBackupsLazyQuery,
};
});
vi.mock('@/utils/timezoneUtils', async () => {
const actualTimezoneUtils = await vi.importActual<any>(
'@/utils/timezoneUtils',
);
return {
...actualTimezoneUtils,
guessTimezone: () => 'Europe/Helsinki',
};
});
vi.mock('@/features/orgs/hooks/useRestoreApplicationDatabasePiTR', () => ({
useRestoreApplicationDatabasePiTR: () => ({
restoreApplicationDatabase: mocks.restoreApplicationDatabase,
loading: false,
}),
}));
vi.mock('@/features/orgs/projects/hooks/useProject', async () => ({
useProject: () => ({ project: mockApplication }),
}));
describe('ImportBackupContent', () => {
beforeAll(() => {
process.env.NEXT_PUBLIC_NHOST_PLATFORM = 'true';
process.env.NEXT_PUBLIC_ENV = 'production';
server.listen();
});
afterAll(() => {
server.close();
vi.restoreAllMocks();
});
test("will display the target project's name and a select with the projects from the same region", async () => {
const user = new TestUserEvent();
server.use(getOrganization);
server.use(getProjectsQuery);
render(<TestComponent />);
expect(
await screen.getByText(
`${mockApplication.name} (${mockApplication.region.name})`,
),
).toBeInTheDocument();
const projectComboBox = await screen.findByRole('combobox');
await user.click(projectComboBox);
// check for only projects from the same region are listed
expect(screen.getByRole('option', { name: /pitr14/i })).toBeInTheDocument();
expect(
screen.getByRole('option', { name: /pitr-not-enabled/i }),
).toBeInTheDocument();
expect(
screen.getByRole('option', { name: /pitr-test/i }),
).toBeInTheDocument();
expect(
screen.queryByRole('option', { name: /pitr-region-test-eu/i }),
).not.toBeInTheDocument();
});
test('that warning is displayed if there are no other projects in the same organization', async () => {
server.use(getOrganization);
server.use(getEmptyProjectsQuery);
render(<TestComponent />);
expect(
await screen.findByText(
/There are no other projects within the region:/i,
),
).toBeInTheDocument();
});
test('will schedule an import from the selected project', async () => {
const user = new TestUserEvent();
server.use(getOrganization);
server.use(getProjectsQuery);
server.use(getPostgresSettings);
mocks.useGetPiTrBaseBackupsLazyQuery.mockImplementation(() => [
fetchPiTRBaseBackups,
{ loading: false },
]);
render(<TestComponent />);
expect(
await screen.getByText(
`${mockApplication.name} (${mockApplication.region.name})`,
),
).toBeInTheDocument();
const projectComboBox = await screen.findByRole('combobox');
await user.click(projectComboBox);
await waitFor(async () => {
await user.click(
screen.getByRole('option', {
name: 'pitr14 (us-east-1)',
}),
);
});
expect(
await screen.getByText('Import backup from pitr14 (us-east-1)'),
).toBeInTheDocument();
const startImportButton = await screen.getByRole('button', {
name: 'Start import',
});
await user.click(startImportButton);
await waitFor(async () =>
expect(
await screen.getByRole('button', { name: 'Import backup' }),
).toBeInTheDocument(),
);
const dateTimePickerButton = await screen.getByRole('button', {
name: /UTC/i,
});
await user.click(dateTimePickerButton);
await waitFor(async () =>
expect(
await screen.getByRole('button', { name: 'Select' }),
).toBeInTheDocument(),
);
await user.click(await screen.getByText('13'));
const hoursInput = await screen.getByLabelText('Hours');
await waitFor(async () => {
await user.type(hoursInput, '18');
});
const updatedDateTimeButton = await screen.getByRole('button', {
name: /UTC/i,
});
expect(updatedDateTimeButton).toHaveTextContent(
'13 Mar 2025, 18:00:05 (UTC+02:00)',
);
await user.click(await screen.getByRole('button', { name: 'Select' }));
await waitFor(async () =>
expect(
await screen.queryByRole('button', { name: 'Select' }),
).not.toBeInTheDocument(),
);
expect(updatedDateTimeButton).toHaveTextContent(
'13 Mar 2025, 18:00:05 (UTC+02:00)',
);
// check checkboxes
await user.click(
await screen.getByLabelText(/I understand that restoring this backup/),
);
await user.click(
await screen.getByLabelText(/I understand this cannot be undone/),
);
await waitFor(async () =>
expect(
await screen.getByRole('button', { name: 'Import backup' }),
).not.toBeDisabled(),
);
await user.click(
await screen.getByRole('button', { name: 'Import backup' }),
);
expect(mocks.restoreApplicationDatabase.mock.calls[0][0].fromAppId).toBe(
'pitr14-id',
);
expect(
mocks.restoreApplicationDatabase.mock.calls[0][0].recoveryTarget,
).toBe('2025-03-13T16:00:05.000Z');
});
test('Pitr is not enabled on project', async () => {
const user = new TestUserEvent();
server.use(getOrganization);
server.use(getProjectsQuery);
server.use(getPiTRNotEnabledPostgresSettings);
render(<TestComponent />);
const projectComboBox = await screen.findByRole('combobox');
await user.click(projectComboBox);
await user.click(
screen.getByRole('option', {
name: 'pitr-not-enabled-usa (us-east-1)',
}),
);
expect(
screen.getByText(
'Point-in-Time Recovery is not enabled on the selected project',
),
).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,356 @@
import {
fetchEmptyPiTRBaseBackups,
fetchPiTRBaseBackups,
mockApplication,
mockMatchMediaValue,
} from '@/tests/mocks';
import tokenQuery from '@/tests/msw/mocks/rest/tokenQuery';
import { render, screen, TestUserEvent, waitFor } from '@/tests/testUtils';
import { setupServer } from 'msw/node';
import { test, vi } from 'vitest';
import { getOrganization } from '@/tests/msw/mocks/graphql/getOrganizationQuery';
import { getProjectQuery } from '@/tests/msw/mocks/graphql/getProjectQuery';
import PointInTimeBackupInfo from './PointInTimeBackupInfo';
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn().mockImplementation(mockMatchMediaValue),
});
const server = setupServer(tokenQuery);
const mocks = vi.hoisted(() => ({
useGetPiTrBaseBackupsLazyQuery: vi.fn(),
fetchPiTRBaseBackups: vi.fn(),
restoreApplicationDatabase: vi.fn(),
}));
vi.mock('@/utils/__generated__/graphql', async () => {
const actual = await vi.importActual<any>('@/utils/__generated__/graphql');
return {
...actual,
useGetPiTrBaseBackupsLazyQuery: mocks.useGetPiTrBaseBackupsLazyQuery,
};
});
vi.mock('@/utils/timezoneUtils', async () => {
const actualTimezoneUtils = await vi.importActual<any>(
'@/utils/timezoneUtils',
);
return {
...actualTimezoneUtils,
guessTimezone: () => 'Europe/Helsinki',
};
});
vi.mock('@/features/orgs/hooks/useRestoreApplicationDatabasePiTR', () => ({
useRestoreApplicationDatabasePiTR: () => ({
restoreApplicationDatabase: mocks.restoreApplicationDatabase,
loading: false,
}),
}));
vi.mock('@/features/orgs/projects/hooks/useProject', async () => ({
useProject: () => ({ project: mockApplication }),
}));
describe('PointInTimeBackupInfo', () => {
beforeAll(() => {
process.env.NEXT_PUBLIC_NHOST_PLATFORM = 'true';
process.env.NEXT_PUBLIC_ENV = 'production';
server.listen();
});
afterEach(() => {
server.resetHandlers();
vi.restoreAllMocks();
});
afterAll(() => {
server.close();
});
test("will display the earliest backup's date with the local timezone", async () => {
server.use(getProjectQuery);
server.use(getOrganization);
mocks.useGetPiTrBaseBackupsLazyQuery.mockImplementation(() => [
fetchPiTRBaseBackups,
{ loading: false },
]);
await waitFor(() =>
render(<PointInTimeBackupInfo appId={mockApplication.id} />),
);
const earliestBackup = await screen.getByTestId('EarliestBackupDateTime');
expect(earliestBackup).toHaveTextContent(
'10 Mar 2025, 05:00:05 (UTC+02:00)',
);
});
test("that the system fetches the earliest backup, displays 'Project has no backups yet' message when no backups exist, and verifies that the restore button is disabled.", async () => {
server.use(getOrganization);
mocks.useGetPiTrBaseBackupsLazyQuery.mockImplementation(() => [
fetchEmptyPiTRBaseBackups,
{ loading: false },
]);
await waitFor(() =>
render(<PointInTimeBackupInfo appId={mockApplication.id} />),
);
const earliestBackup = await screen.getByText(
'Project has no backups yet.',
);
expect(earliestBackup).toBeInTheDocument();
const startRestoreButton = await screen.getByRole('button', {
name: 'Start restore',
});
expect(startRestoreButton).toBeDisabled();
});
test('will update the date after the timezone has changed', async () => {
server.use(getOrganization);
mocks.useGetPiTrBaseBackupsLazyQuery.mockImplementation(() => [
fetchPiTRBaseBackups,
{ loading: false },
]);
await waitFor(() => render(<PointInTimeBackupInfo appId="randomId" />));
const user = new TestUserEvent();
const earliestBackup = await screen.getByTestId('EarliestBackupDateTime');
expect(earliestBackup).toHaveTextContent(
'10 Mar 2025, 05:00:05 (UTC+02:00)',
);
const changeTimezoneButton = await screen.getByRole('button', {
name: 'Change timezone',
});
await user.click(changeTimezoneButton);
const tzInput = await screen.getByPlaceholderText('Search timezones...');
expect(tzInput).toBeInTheDocument();
await user.type(tzInput, 'Asia/Amman{ArrowDown}{Enter}');
await waitFor(() => expect(tzInput).not.toBeInTheDocument());
const updatedEarliestBackup = await screen.getByTestId(
'EarliestBackupDateTime',
);
expect(updatedEarliestBackup).toHaveTextContent(
'10 Mar 2025, 06:00:05 (UTC+03:00)',
);
});
test('will schedule a restore', async () => {
server.use(getOrganization);
mocks.useGetPiTrBaseBackupsLazyQuery.mockImplementation(() => [
fetchPiTRBaseBackups,
{ loading: false },
]);
await waitFor(() =>
render(<PointInTimeBackupInfo appId={mockApplication.id} />),
);
const user = new TestUserEvent();
const startRestoreButton = await screen.getByRole('button', {
name: 'Start restore',
});
await user.click(startRestoreButton);
await waitFor(async () =>
expect(
await screen.getByText('Recover your database from a backup'),
).toBeInTheDocument(),
);
const dateTimePickerButton = await screen.getByRole('button', {
name: /UTC/i,
});
await user.click(dateTimePickerButton);
await waitFor(async () =>
expect(
await screen.getByRole('button', { name: 'Select' }),
).toBeInTheDocument(),
);
await user.click(await screen.getByText('13'));
const hoursInput = await screen.getByLabelText('Hours');
await user.type(hoursInput, '18');
const updatedDateTimeButton = await screen.getByRole('button', {
name: /UTC/i,
});
expect(updatedDateTimeButton).toHaveTextContent(
'13 Mar 2025, 18:00:05 (UTC+02:00)',
);
await user.click(await screen.getByRole('button', { name: 'Select' }));
await waitFor(async () =>
expect(
await screen.queryByRole('button', { name: 'Select' }),
).not.toBeInTheDocument(),
);
expect(updatedDateTimeButton).toHaveTextContent(
'13 Mar 2025, 18:00:05 (UTC+02:00)',
);
expect(
await screen.getByRole('button', { name: 'Restore backup' }),
).toBeDisabled();
// check checkboxes
await user.click(
await screen.getByLabelText(/I understand that restoring this backup/),
);
expect(
await screen.getByLabelText(/I understand that restoring this backup/),
).toBeChecked();
expect(
await screen.getByRole('button', { name: 'Restore backup' }),
).toBeDisabled();
await user.click(
await screen.getByLabelText(/I understand this cannot be undone/),
);
expect(
await screen.getByLabelText(/I understand this cannot be undone/),
).toBeChecked();
await waitFor(async () =>
expect(
await screen.getByRole('button', { name: 'Restore backup' }),
).not.toBeDisabled(),
);
await user.click(
await screen.getByRole('button', { name: 'Restore backup' }),
);
expect(
mocks.restoreApplicationDatabase.mock.calls[0][0].fromAppId,
).toBeNull();
expect(
mocks.restoreApplicationDatabase.mock.calls[0][0].recoveryTarget,
).toBe('2025-03-13T16:00:05.000Z');
// call the onCompleted cb
await waitFor(() => mocks.restoreApplicationDatabase.mock.calls[0][1]());
expect(
screen.getByText('Backup has been scheduled successfully.'),
).toBeInTheDocument();
});
test('that dates before the earliest backup cannot be selected', async () => {
server.use(getOrganization);
mocks.useGetPiTrBaseBackupsLazyQuery.mockImplementation(() => [
fetchPiTRBaseBackups,
{ loading: false },
]);
await waitFor(() =>
render(<PointInTimeBackupInfo appId={mockApplication.id} />),
);
const user = new TestUserEvent();
const startRestoreButton = await screen.getByRole('button', {
name: 'Start restore',
});
await user.click(startRestoreButton);
await waitFor(async () =>
expect(
await screen.getByText('Recover your database from a backup'),
).toBeInTheDocument(),
);
const dateTimePickerButton = await screen.getByRole('button', {
name: /UTC/i,
});
await user.click(dateTimePickerButton);
await waitFor(async () =>
expect(
await screen.getByRole('button', { name: 'Select' }),
).toBeInTheDocument(),
);
expect(await screen.getByText('March 2025')).toBeInTheDocument();
expect(await screen.getByText('9')).toBeDisabled();
expect(await screen.getAllByText('1')[0]).toBeDisabled();
expect(await screen.getAllByText('5')[0]).toBeDisabled();
expect(await screen.getByText('10')).not.toBeDisabled();
expect(await screen.getByText('15')).not.toBeDisabled();
const hoursInput = await screen.getByLabelText('Hours');
await user.type(hoursInput, '{ArrowDown}');
expect(await screen.getByLabelText('Hours')).toHaveValue('04');
const updatedDateTimeButton = await screen.getByRole('button', {
name: /UTC/i,
});
expect(updatedDateTimeButton).toHaveTextContent(
'10 Mar 2025, 04:00:05 (UTC+02:00)',
);
expect(
await screen.queryByRole('button', { name: 'Select' }),
).toBeDisabled();
expect(
await screen.queryByText(
'Selected date and time is before the earliest available backup',
),
).toBeInTheDocument();
expect(
await screen.getByRole('button', {
name: /UTC/i,
}),
).toHaveClass('border-destructive');
});
test('Learn more link is displayed in the "footer" and aligned to the left and the "Start restore" button is to the right', async () => {
server.use(getOrganization);
mocks.useGetPiTrBaseBackupsLazyQuery.mockImplementation(() => [
fetchPiTRBaseBackups,
{ loading: false },
]);
await waitFor(() =>
render(<PointInTimeBackupInfo appId={mockApplication.id} showLink />),
);
const learMoreAboutPiTRLink = screen.getByText(
/Learn more about Point-in-Time Recover/,
);
expect(learMoreAboutPiTRLink).toBeInTheDocument();
const linkWrapper = learMoreAboutPiTRLink.closest('div');
expect(linkWrapper).toHaveClass('justify-between');
expect(linkWrapper).not.toHaveClass('justify-end');
});
test('Learn more link is in not displayed in the "footer" the "Start restore" button is aligned to the right', async () => {
server.use(getOrganization);
mocks.useGetPiTrBaseBackupsLazyQuery.mockImplementation(() => [
fetchPiTRBaseBackups,
{ loading: false },
]);
await waitFor(() =>
render(<PointInTimeBackupInfo appId={mockApplication.id} />),
);
expect(
screen.queryByText(/Learn more about Point-in-Time Recover/),
).not.toBeInTheDocument();
const startRestoreButton = screen.getByText('Start restore');
const linkWrapper = startRestoreButton.closest('div');
expect(linkWrapper).not.toHaveClass('justify-between');
expect(linkWrapper).toHaveClass('justify-end');
});
});

View File

@@ -0,0 +1,188 @@
import { mockMatchMediaValue } from '@/tests/mocks';
import { render, screen, TestUserEvent } from '@/tests/testUtils';
import { vi } from 'vitest';
import DatabasePiTRSettings from './DatabasePiTRSettings';
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn().mockImplementation(mockMatchMediaValue),
});
function getCurrentOrg({ isFree }: { isFree: boolean }) {
return {
org: {
plan: {
isFree,
},
},
};
}
function mockUseGetPostgresSettingsQueryResponse({
retention,
}: {
retention: number | null;
}) {
const pitr = retention === null ? null : { retention };
return {
data: {
config: {
postgres: {
pitr,
},
},
},
};
}
const mocks = vi.hoisted(() => ({
useCurrentOrg: vi.fn(),
useUpdateConfigMutation: vi.fn(),
useGetPostgresSettingsQuery: vi.fn(),
updateConfigMock: vi.fn(),
}));
vi.mock('@/features/orgs/projects/hooks/useCurrentOrg', async () => {
const actualCurrentOrg = await vi.importActual<any>(
'@/features/orgs/projects/hooks/useCurrentOrg',
);
return {
...actualCurrentOrg,
useCurrentOrg: mocks.useCurrentOrg,
};
});
vi.mock('@/utils/__generated__/graphql', async () => {
const actual = await vi.importActual<any>('@/utils/__generated__/graphql');
return {
...actual,
useUpdateConfigMutation: mocks.useUpdateConfigMutation,
useGetPostgresSettingsQuery: mocks.useGetPostgresSettingsQuery,
};
});
afterEach(() => {
mocks.useCurrentOrg.mockRestore();
mocks.updateConfigMock.mockRestore();
mocks.useUpdateConfigMutation.mockRestore();
mocks.useGetPostgresSettingsQuery.mockRestore();
});
test('If the org is free the switch should not be available and the save button is disabled', async () => {
mocks.useCurrentOrg.mockImplementation(() => getCurrentOrg({ isFree: true }));
mocks.useUpdateConfigMutation.mockImplementation(() => [
mocks.updateConfigMock,
]);
mocks.useGetPostgresSettingsQuery.mockImplementation(() =>
mockUseGetPostgresSettingsQueryResponse({ retention: null }),
);
render(<DatabasePiTRSettings />);
const saveButton = await screen.findByRole('button', {
name: 'Save',
});
expect(saveButton).toBeDisabled();
expect(await screen.queryByRole('checkbox')).not.toBeInTheDocument();
});
test('the Save button is disabled until the switch in the header is not touched', async () => {
mocks.useCurrentOrg.mockImplementation(() =>
getCurrentOrg({ isFree: false }),
);
mocks.useUpdateConfigMutation.mockImplementation(() => [
mocks.updateConfigMock,
]);
mocks.useGetPostgresSettingsQuery.mockImplementation(() =>
mockUseGetPostgresSettingsQueryResponse({ retention: null }),
);
const user = new TestUserEvent();
render(<DatabasePiTRSettings />);
const saveButton = await screen.findByRole('button', {
name: 'Save',
});
expect(saveButton).toBeDisabled();
const PiTR = screen.getByRole('checkbox');
await user.click(PiTR);
expect(PiTR).toBeChecked();
expect(
await screen.findByRole('button', {
name: 'Save',
}),
).not.toBeDisabled();
});
test('should disable the savebutton after toggling back to original state', async () => {
mocks.useCurrentOrg.mockImplementation(() =>
getCurrentOrg({ isFree: false }),
);
mocks.useUpdateConfigMutation.mockImplementation(() => [
mocks.updateConfigMock,
]);
mocks.useGetPostgresSettingsQuery.mockImplementation(() =>
mockUseGetPostgresSettingsQueryResponse({ retention: 7 }),
);
const user = new TestUserEvent();
render(<DatabasePiTRSettings />);
await user.click(screen.getByRole('checkbox'));
expect(screen.getByRole('checkbox')).not.toBeChecked();
await user.click(screen.getByRole('checkbox'));
expect(
await screen.findByRole('button', {
name: 'Save',
}),
).toBeDisabled();
});
test('should send { retention: 7 } when enabling PiTR', async () => {
mocks.useCurrentOrg.mockImplementation(() =>
getCurrentOrg({ isFree: false }),
);
mocks.useUpdateConfigMutation.mockImplementation(() => [
mocks.updateConfigMock,
]);
mocks.useGetPostgresSettingsQuery.mockImplementation(() =>
mockUseGetPostgresSettingsQueryResponse({ retention: null }),
);
const user = new TestUserEvent();
render(<DatabasePiTRSettings />);
await user.click(screen.getByRole('checkbox'));
expect(screen.getByRole('checkbox')).toBeChecked();
await user.click(
screen.getByRole('button', {
name: 'Save',
}),
);
expect(
mocks.updateConfigMock.mock.calls[0][0].variables.config.postgres.pitr,
).toStrictEqual({ retention: 7 });
});
test('should send { pitr: null } when disabling PiTR', async () => {
mocks.useCurrentOrg.mockImplementation(() =>
getCurrentOrg({ isFree: false }),
);
mocks.useUpdateConfigMutation.mockImplementation(() => [
mocks.updateConfigMock,
]);
mocks.useGetPostgresSettingsQuery.mockImplementation(() =>
mockUseGetPostgresSettingsQueryResponse({ retention: 7 }),
);
const user = new TestUserEvent();
render(<DatabasePiTRSettings />);
await user.click(screen.getByRole('checkbox'));
expect(screen.getByRole('checkbox')).not.toBeChecked();
await user.click(
screen.getByRole('button', {
name: 'Save',
}),
);
expect(
mocks.updateConfigMock.mock.calls[0][0].variables.config.postgres,
).toStrictEqual({ pitr: null });
});

View File

@@ -1,4 +1,5 @@
import { mockMatchMediaValue, mockRouter } from '@/tests/mocks';
import { getProjectQuery } from '@/tests/msw/mocks/graphql/getProjectQuery';
import { getProPlanOnlyQuery } from '@/tests/msw/mocks/graphql/plansQuery';
import {
resourcesAvailableQuery,
@@ -8,12 +9,10 @@ import {
import updateConfigMutation from '@/tests/msw/mocks/graphql/updateConfigMutation';
import tokenQuery from '@/tests/msw/mocks/rest/tokenQuery';
import {
clickOnElement,
fireEvent,
render,
screen,
userClearElement,
userType,
TestUserEvent,
waitFor,
waitForElementToBeRemoved,
within,
@@ -22,7 +21,6 @@ import {
RESOURCE_MEMORY_MULTIPLIER,
RESOURCE_VCPU_MULTIPLIER,
} from '@/utils/constants/common';
import userEvent from '@testing-library/user-event';
import { setupServer } from 'msw/node';
import { expect, test, vi } from 'vitest';
import ResourcesForm from './ResourcesForm';
@@ -39,6 +37,7 @@ vi.mock('next/router', () => ({
const server = setupServer(
tokenQuery,
resourcesAvailableQuery,
getProjectQuery,
getProPlanOnlyQuery,
);
@@ -54,9 +53,14 @@ afterAll(() => {
});
// Note: Workaround based on https://github.com/testing-library/user-event/issues/871#issuecomment-1059317998
function changeSliderValue(slider: HTMLElement, value: number) {
fireEvent.input(slider, { target: { value } });
fireEvent.change(slider, { target: { value } });
async function changeSliderValue(slider: HTMLElement, value: number) {
await waitFor(() => {
fireEvent.input(slider, { target: { value } });
});
await waitFor(() => {
fireEvent.change(slider, { target: { value } });
});
}
test('should show an empty state message that the feature must be enabled if no data is available', async () => {
@@ -69,7 +73,7 @@ test('should show an empty state message that the feature must be enabled if no
test('should show the sliders if the switch is enabled', async () => {
server.use(resourcesUnavailableQuery);
const user = userEvent.setup();
const user = new TestUserEvent();
render(<ResourcesForm />);
@@ -77,8 +81,10 @@ test('should show the sliders if the switch is enabled', async () => {
await user.click(screen.getByRole('checkbox'));
// Wait for the empty state message to disappear and sliders to appear
expect(screen.queryByText(/enable this feature/i)).not.toBeInTheDocument();
expect(screen.getAllByRole('slider')).toHaveLength(9);
const sliders = screen.getAllByRole('slider');
expect(sliders).toHaveLength(9);
});
test('should not show an empty state message if there is data available', async () => {
@@ -101,12 +107,14 @@ test('should show a warning message if not all the resources are allocated', asy
await screen.findByRole('slider', { name: /total available vcpu/i }),
).toBeInTheDocument();
changeSliderValue(
screen.getByRole('slider', {
name: /total available vcpu/i,
}),
9 * RESOURCE_VCPU_MULTIPLIER,
);
await waitFor(() => {
changeSliderValue(
screen.getByRole('slider', {
name: /total available vcpu/i,
}),
9 * RESOURCE_VCPU_MULTIPLIER,
);
});
expect(screen.getByText(/^vcpus:/i)).toHaveTextContent(/vcpus: 9/i);
expect(screen.getByText(/^memory:/i)).toHaveTextContent(/memory: 18432 mib/i);
@@ -136,6 +144,7 @@ test('should update the price when the top slider is changed', async () => {
});
test('should show a validation error when the form is submitted when not everything is allocated', async () => {
const user = new TestUserEvent();
render(<ResourcesForm />);
expect(
@@ -151,7 +160,7 @@ test('should show a validation error when the form is submitted when not everyth
9 * RESOURCE_VCPU_MULTIPLIER,
);
await clickOnElement(screen.getByRole('button', { name: /save/i }));
await user.click(screen.getByRole('button', { name: /save/i }));
expect(
screen.getByText(/you have 1 vcpus and 2048 mib of memory unused./i),
@@ -161,6 +170,7 @@ test('should show a validation error when the form is submitted when not everyth
});
test('should show a confirmation dialog when the form is submitted', async () => {
const user = new TestUserEvent();
server.use(updateConfigMutation);
render(<ResourcesForm />);
@@ -209,7 +219,7 @@ test('should show a confirmation dialog when the form is submitted', async () =>
5 * RESOURCE_MEMORY_MULTIPLIER,
);
await clickOnElement(screen.getByRole('button', { name: /save/i }));
await user.click(screen.getByRole('button', { name: /save/i }));
expect(await screen.findByRole('dialog')).toBeInTheDocument();
expect(
@@ -239,7 +249,7 @@ test('should show a confirmation dialog when the form is submitted', async () =>
// and we need to return the updated values
server.use(resourcesUpdatedQuery);
await clickOnElement(screen.getByRole('button', { name: /confirm/i }));
await user.click(screen.getByRole('button', { name: /confirm/i }));
await waitForElementToBeRemoved(() => screen.queryByRole('dialog'));
@@ -254,19 +264,20 @@ test('should show a confirmation dialog when the form is submitted', async () =>
});
test('should display a red button when custom resources are disabled', async () => {
const user = new TestUserEvent();
render(<ResourcesForm />);
expect(
await screen.findByRole('slider', { name: /total available vcpu/i }),
).toBeInTheDocument();
await clickOnElement(screen.getAllByRole('checkbox')[0]);
await user.click(screen.getAllByRole('checkbox')[0]);
await waitFor(() => {});
expect(screen.getByText(/enable this feature/i)).toBeInTheDocument();
await clickOnElement(screen.getByRole('button', { name: /save/i }));
await user.click(screen.getByRole('button', { name: /save/i }));
expect(await screen.findByRole('dialog')).toBeInTheDocument();
@@ -279,6 +290,7 @@ test('should display a red button when custom resources are disabled', async ()
});
test('should hide the pricing information when custom resource allocation is disabled', async () => {
const user = new TestUserEvent();
server.use(updateConfigMutation);
render(<ResourcesForm />);
@@ -287,15 +299,15 @@ test('should hide the pricing information when custom resource allocation is dis
await screen.findByRole('slider', { name: /total available vcpu/i }),
).toBeInTheDocument();
await clickOnElement(screen.getAllByRole('checkbox')[0]);
await user.click(screen.getAllByRole('checkbox')[0]);
await clickOnElement(screen.getByRole('button', { name: /save/i }));
await user.click(screen.getByRole('button', { name: /save/i }));
expect(await screen.findByRole('dialog')).toBeInTheDocument();
server.use(resourcesUnavailableQuery);
await clickOnElement(screen.getByRole('button', { name: /confirm/i }));
await user.click(screen.getByRole('button', { name: /confirm/i }));
await waitForElementToBeRemoved(() => screen.queryByRole('dialog'));
@@ -324,6 +336,7 @@ test('should show a warning message when resources are overallocated', async ()
});
test('should change pricing based on selected replicas', async () => {
const user = new TestUserEvent();
render(<ResourcesForm />);
expect(
@@ -336,9 +349,9 @@ test('should change pricing based on selected replicas', async () => {
const hasuraReplicasInput = screen.getAllByPlaceholderText('Replicas')[0];
await clickOnElement(hasuraReplicasInput);
await userClearElement(hasuraReplicasInput);
await userType(hasuraReplicasInput, '2');
await user.click(hasuraReplicasInput);
await user.clear(hasuraReplicasInput);
await user.type(hasuraReplicasInput, '2');
await new Promise((resolve) => {
setTimeout(resolve, 1000);
@@ -350,9 +363,9 @@ test('should change pricing based on selected replicas', async () => {
),
);
await clickOnElement(hasuraReplicasInput);
await userClearElement(hasuraReplicasInput);
await userType(hasuraReplicasInput, '1');
await user.click(hasuraReplicasInput);
await user.clear(hasuraReplicasInput);
await user.type(hasuraReplicasInput, '1');
await waitFor(() => {
expect(screen.getByText(/approximate cost:/i)).toHaveTextContent(
@@ -362,7 +375,7 @@ test('should change pricing based on selected replicas', async () => {
});
test('should validate if vCPU and Memory match the 1:2 ratio if more than 1 replica is selected', async () => {
const user = userEvent.setup();
const user = new TestUserEvent();
render(<ResourcesForm />);
@@ -411,18 +424,18 @@ test('should validate if vCPU and Memory match the 1:2 ratio if more than 1 repl
});
test('should take replicas into account when confirming the resources', async () => {
const user = new TestUserEvent();
server.use(updateConfigMutation);
render(<ResourcesForm />);
expect(
await screen.findByRole('slider', { name: /total available vcpu/i }),
).toBeInTheDocument();
// Wait for initial render
const totalVCPUSlider = await screen.findByRole('slider', {
name: /total available vcpu/i,
});
expect(totalVCPUSlider).toBeInTheDocument();
changeSliderValue(
screen.getByRole('slider', {
name: /total available vcpu/i,
}),
8.5 * RESOURCE_VCPU_MULTIPLIER,
);
// Change slider values and wait for updates
changeSliderValue(totalVCPUSlider, 8.5 * RESOURCE_VCPU_MULTIPLIER);
// setting up database
changeSliderValue(
@@ -435,9 +448,9 @@ test('should take replicas into account when confirming the resources', async ()
);
const hasuraReplicasInput = screen.getAllByPlaceholderText('Replicas')[0];
await clickOnElement(hasuraReplicasInput);
await userClearElement(hasuraReplicasInput);
await userType(hasuraReplicasInput, '3');
await user.click(hasuraReplicasInput);
await user.clear(hasuraReplicasInput);
await user.type(hasuraReplicasInput, '3');
changeSliderValue(
screen.getByRole('slider', { name: /hasura graphql vcpu/i }),
@@ -450,9 +463,9 @@ test('should take replicas into account when confirming the resources', async ()
const authReplicasInput = screen.getAllByPlaceholderText('Replicas')[1];
// setting up auth
await clickOnElement(authReplicasInput);
await userClearElement(authReplicasInput);
await userType(authReplicasInput, '2');
await user.click(authReplicasInput);
await user.clear(authReplicasInput);
await user.type(authReplicasInput, '2');
changeSliderValue(
screen.getByRole('slider', { name: /auth vcpu/i }),
@@ -465,9 +478,9 @@ test('should take replicas into account when confirming the resources', async ()
const storageReplicasInput = screen.getAllByPlaceholderText('Replicas')[2];
// setting up storage
await clickOnElement(storageReplicasInput);
await userClearElement(storageReplicasInput);
await userType(storageReplicasInput, '4');
await user.click(storageReplicasInput);
await user.clear(storageReplicasInput);
await user.type(storageReplicasInput, '4');
changeSliderValue(
screen.getByRole('slider', { name: /storage vcpu/i }),
@@ -478,11 +491,12 @@ test('should take replicas into account when confirming the resources', async ()
5 * RESOURCE_MEMORY_MULTIPLIER,
);
await clickOnElement(screen.getByRole('button', { name: /save/i }));
// Click save and wait for dialog
await user.click(screen.getByRole('button', { name: /save/i }));
expect(await screen.findByRole('dialog')).toBeInTheDocument();
const dialog = screen.getByRole('dialog');
// Wait for dialog to appear
const dialog = await screen.findByRole('dialog', {}, { timeout: 5000 });
expect(dialog).toBeInTheDocument();
expect(
within(dialog).getByText(/postgresql database/i).parentElement,
@@ -500,8 +514,6 @@ test('should take replicas into account when confirming the resources', async ()
/2\.5 vcpu \+ 5120 mib \(4 replicas\)/i,
);
// total must contain the sum of all resources when replicas are taken into
// account
expect(within(dialog).getByText(/total/i).parentElement).toHaveTextContent(
/22\.5 vcpu \+ 46080 mib/i,
);

View File

@@ -43,6 +43,12 @@ export const resourcesAvailableQuery = nhostGraphQLLink.query(
cpu: 2000,
memory: 4096,
},
enablePublicAccess: null,
storage: {
capacity: 1,
},
autoscaler: null,
networking: null,
replicas: 1,
},
},
@@ -52,6 +58,8 @@ export const resourcesAvailableQuery = nhostGraphQLLink.query(
cpu: 2000,
memory: 4096,
},
autoscaler: null,
networking: null,
replicas: 1,
},
},
@@ -61,6 +69,8 @@ export const resourcesAvailableQuery = nhostGraphQLLink.query(
cpu: 2000,
memory: 4096,
},
autoscaler: null,
networking: null,
replicas: 1,
},
},
@@ -70,6 +80,8 @@ export const resourcesAvailableQuery = nhostGraphQLLink.query(
cpu: 2000,
memory: 4096,
},
autoscaler: null,
networking: null,
replicas: 1,
},
},

View File

@@ -1,4 +1,5 @@
/* eslint-disable no-restricted-imports */
/* eslint-disable max-classes-per-file */
import { DialogProvider } from '@/components/common/DialogProvider';
import { UIProvider } from '@/components/common/UIProvider';
import { RetryableErrorBoundary } from '@/components/presentational/RetryableErrorBoundary';
@@ -22,7 +23,10 @@ import {
waitForElementToBeRemoved as rtlWaitForElementToBeRemoved,
waitFor,
} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import userEvent, {
type Options,
type UserEvent,
} from '@testing-library/user-event';
import { RouterContext } from 'next/dist/shared/lib/router-context.shared-runtime';
import type { PropsWithChildren, ReactElement } from 'react';
import { Toaster } from 'react-hot-toast';
@@ -32,6 +36,14 @@ import nhostGraphQLLink from './msw/mocks/graphql/nhostGraphQLLink';
// Client-side cache, shared for the whole session of the user in the browser.
const emotionCache = createEmotionCache();
/* Workaround to avoid error importing type declarations for typeOptions from @testing-library/user-event */
export interface TypeOptions {
skipClick?: Options['skipClick'];
skipAutoClose?: Options['skipAutoClose'];
initialSelectionStart?: number;
initialSelectionEnd?: number;
}
process.env = {
TEST_MODE: 'true',
NODE_ENV: 'development',
@@ -162,25 +174,30 @@ export const mockPointerEvent = () => {
window.HTMLElement.prototype.hasPointerCapture = vi.fn();
};
export async function clickOnElement(element: Element) {
const user = userEvent.setup();
await waitFor(async () => {
await user.click(element);
});
}
export class TestUserEvent {
private user: UserEvent;
export async function userType(element: Element, value: string, options?: any) {
const user = userEvent.setup();
await waitFor(async () => {
await user.type(element, value, options);
});
}
constructor() {
this.user = userEvent.setup();
}
export async function userClearElement(element: Element) {
const user = userEvent.setup();
await waitFor(async () => {
await user.clear(element);
});
async click(element: Element) {
await waitFor(async () => {
await this.user.click(element);
});
}
async type(element: Element, value: string, options?: TypeOptions) {
await waitFor(async () => {
await this.user.type(element, value, options);
});
}
async clear(element: Element) {
await waitFor(async () => {
await this.user.clear(element);
});
}
}
export * from '@testing-library/react';

View File

@@ -9,7 +9,7 @@
"check": "svelte-kit sync && svelte-check --tsconfig ./jsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./jsconfig.json --watch",
"install-browsers": "pnpm playwright install && pnpm playwright install-deps",
"test": "pnpm install-browsers && pnpm playwright test",
"e2e": "pnpm install-browsers && pnpm playwright test",
"lint": "eslint ."
},
"devDependencies": {