Browse Source

test(crons): Add test to MonitorForm (#62440)

Tests both creation and editing
Evan Purkhiser 1 year ago
parent
commit
87b0de1d7b

+ 46 - 0
fixtures/js-stubs/monitor.tsx

@@ -0,0 +1,46 @@
+import {
+  type Monitor,
+  MonitorStatus,
+  MonitorType,
+  ScheduleType,
+} from 'sentry/views/monitors/types';
+
+import {Project} from './project';
+
+export function MonitorFixture(params: Partial<Monitor> = {}): Monitor {
+  return {
+    id: '',
+    isMuted: false,
+    name: 'My Monitor',
+    project: Project(),
+    slug: 'my-monitor',
+    status: 'active',
+    type: MonitorType.CRON_JOB,
+    config: {
+      checkin_margin: 5,
+      max_runtime: 10,
+      timezone: 'America/Los_Angeles',
+      alert_rule_id: 1234,
+      failure_issue_threshold: 2,
+      recovery_threshold: 2,
+      schedule_type: ScheduleType.CRONTAB,
+      schedule: '10 * * * *',
+    },
+    dateCreated: '2023-01-01T00:00:00Z',
+    environments: [
+      {
+        dateCreated: '2023-01-01T00:10:00Z',
+        isMuted: false,
+        lastCheckIn: '2023-12-25T17:13:00Z',
+        name: 'production',
+        nextCheckIn: '2023-12-25T16:10:00Z',
+        nextCheckInLatest: '2023-12-25T15:15:00Z',
+        status: MonitorStatus.OK,
+      },
+    ],
+    alertRule: {
+      targets: [{targetIdentifier: 1, targetType: 'Member'}],
+    },
+    ...params,
+  };
+}

+ 278 - 0
static/app/views/monitors/components/monitorForm.spec.tsx

@@ -0,0 +1,278 @@
+import selectEvent from 'react-select-event';
+import {Member} from 'sentry-fixture/member';
+import {MonitorFixture} from 'sentry-fixture/monitor';
+import {Organization} from 'sentry-fixture/organization';
+import {Team} from 'sentry-fixture/team';
+import {User} from 'sentry-fixture/user';
+
+import {initializeOrg} from 'sentry-test/initializeOrg';
+import {render, screen, userEvent} from 'sentry-test/reactTestingLibrary';
+
+import {useMembers} from 'sentry/utils/useMembers';
+import useProjects from 'sentry/utils/useProjects';
+import {useTeams} from 'sentry/utils/useTeams';
+import MonitorForm from 'sentry/views/monitors/components/monitorForm';
+import {ScheduleType} from 'sentry/views/monitors/types';
+
+jest.mock('sentry/utils/useProjects');
+jest.mock('sentry/utils/useTeams');
+jest.mock('sentry/utils/useMembers');
+
+describe('MonitorForm', function () {
+  const organization = Organization({features: ['issue-platform']});
+  const member = Member({user: User({name: 'John Smith'})});
+  const team = Team({slug: 'test-team'});
+  const {project, routerContext} = initializeOrg({organization});
+
+  beforeEach(() => {
+    jest.mocked(useProjects).mockReturnValue({
+      fetchError: null,
+      fetching: false,
+      hasMore: false,
+      initiallyLoaded: false,
+      onSearch: jest.fn(),
+      placeholders: [],
+      projects: [project],
+    });
+
+    jest.mocked(useTeams).mockReturnValue({
+      fetchError: null,
+      fetching: false,
+      hasMore: false,
+      initiallyLoaded: false,
+      loadMore: jest.fn(),
+      onSearch: jest.fn(),
+      teams: [team],
+    });
+
+    jest.mocked(useMembers).mockReturnValue({
+      fetchError: null,
+      fetching: false,
+      hasMore: false,
+      initiallyLoaded: false,
+      loadMore: jest.fn(),
+      onSearch: jest.fn(),
+      members: [member.user!],
+    });
+  });
+
+  it('displays human readable schedule', async function () {
+    render(
+      <MonitorForm
+        apiMethod="POST"
+        apiEndpoint={`/organizations/${organization.slug}/monitors/`}
+        onSubmitSuccess={jest.fn()}
+      />,
+      {context: routerContext, organization}
+    );
+
+    const schedule = screen.getByRole('textbox', {name: 'Crontab Schedule'});
+
+    await userEvent.clear(schedule);
+    await userEvent.type(schedule, '5 * * * *');
+    expect(screen.getByText('"At 5 minutes past the hour"')).toBeInTheDocument();
+  });
+
+  it('submits a new monitor', async function () {
+    const mockHandleSubmitSuccess = jest.fn();
+
+    const apiEndpont = `/organizations/${organization.slug}/monitors/`;
+
+    render(
+      <MonitorForm
+        apiMethod="POST"
+        apiEndpoint={apiEndpont}
+        onSubmitSuccess={mockHandleSubmitSuccess}
+        submitLabel="Add Monitor"
+      />,
+      {context: routerContext, organization}
+    );
+
+    await userEvent.type(screen.getByRole('textbox', {name: 'Name'}), 'My Monitor');
+
+    await selectEvent.select(
+      screen.getByRole('textbox', {name: 'Project'}),
+      project.slug
+    );
+
+    const schedule = screen.getByRole('textbox', {name: 'Crontab Schedule'});
+    await userEvent.clear(schedule);
+    await userEvent.type(schedule, '5 * * * *');
+
+    await selectEvent.select(
+      screen.getByRole('textbox', {name: 'Timezone'}),
+      'Los Angeles'
+    );
+
+    await userEvent.type(screen.getByRole('spinbutton', {name: 'Grace Period'}), '5');
+    await userEvent.type(screen.getByRole('spinbutton', {name: 'Max Runtime'}), '20');
+
+    await userEvent.type(
+      screen.getByRole('spinbutton', {name: 'Failure Tolerance'}),
+      '4'
+    );
+    await userEvent.type(
+      screen.getByRole('spinbutton', {name: 'Recovery Tolerance'}),
+      '2'
+    );
+
+    const notifySelect = screen.getByRole('textbox', {name: 'Notify'});
+
+    selectEvent.openMenu(notifySelect);
+    expect(
+      screen.getByRole('menuitemcheckbox', {name: 'John Smith'})
+    ).toBeInTheDocument();
+    expect(
+      screen.getByRole('menuitemcheckbox', {name: '#test-team'})
+    ).toBeInTheDocument();
+
+    await selectEvent.select(notifySelect, 'John Smith');
+
+    const submitMock = MockApiClient.addMockResponse({
+      url: apiEndpont,
+      method: 'POST',
+    });
+
+    await userEvent.click(screen.getByRole('button', {name: 'Add Monitor'}));
+
+    const config = {
+      checkin_margin: '5',
+      max_runtime: '20',
+      failure_issue_threshold: '4',
+      recovery_threshold: '2',
+      schedule: '5 * * * *',
+      schedule_type: 'crontab',
+      timezone: 'America/Los_Angeles',
+    };
+
+    const alertRule = {
+      environment: undefined,
+      targets: [{targetIdentifier: 1, targetType: 'Member'}],
+    };
+
+    expect(submitMock).toHaveBeenCalledWith(
+      expect.anything(),
+      expect.objectContaining({
+        data: {
+          name: 'My Monitor',
+          project: 'project-slug',
+          type: 'cron_job',
+          config,
+          alertRule,
+        },
+      })
+    );
+
+    expect(mockHandleSubmitSuccess).toHaveBeenCalled();
+  });
+
+  it('prefills with an existing monitor', async function () {
+    const monitor = MonitorFixture({project});
+
+    const apiEndpont = `/organizations/${organization.slug}/monitors/${monitor.slug}/`;
+
+    if (monitor.config.schedule_type !== ScheduleType.CRONTAB) {
+      throw new Error('Fixture is not crontab');
+    }
+
+    render(
+      <MonitorForm
+        monitor={monitor}
+        apiMethod="POST"
+        apiEndpoint={apiEndpont}
+        onSubmitSuccess={jest.fn()}
+        submitLabel="Edit Monitor"
+      />,
+      {context: routerContext, organization}
+    );
+
+    // Name and slug
+    expect(screen.getByRole('textbox', {name: 'Name'})).toHaveValue(monitor.name);
+    expect(screen.getByRole('textbox', {name: 'Slug'})).toHaveValue(monitor.slug);
+
+    // Project
+    expect(screen.getByRole('textbox', {name: 'Project'})).toBeDisabled();
+    expect(screen.getByText(project.slug)).toBeInTheDocument();
+
+    // Schedule type
+    selectEvent.openMenu(screen.getByRole('textbox', {name: 'Schedule Type'}));
+    const crontabOption = screen.getByRole('menuitemradio', {name: 'Crontab'});
+    expect(crontabOption).toBeChecked();
+    await userEvent.click(crontabOption);
+
+    // Schedule value
+    expect(screen.getByRole('textbox', {name: 'Crontab Schedule'})).toHaveValue(
+      monitor.config.schedule
+    );
+
+    // Schedule timezone
+    selectEvent.openMenu(screen.getByRole('textbox', {name: 'Timezone'}));
+    const losAngelesOption = screen.getByRole('menuitemradio', {name: 'Los Angeles'});
+    expect(losAngelesOption).toBeChecked();
+    await userEvent.click(losAngelesOption);
+
+    // Margins
+    expect(screen.getByRole('spinbutton', {name: 'Grace Period'})).toHaveValue(5);
+    expect(screen.getByRole('spinbutton', {name: 'Max Runtime'})).toHaveValue(10);
+
+    // Tolerances
+    expect(screen.getByRole('spinbutton', {name: 'Failure Tolerance'})).toHaveValue(2);
+    expect(screen.getByRole('spinbutton', {name: 'Recovery Tolerance'})).toHaveValue(2);
+
+    // Alert rule configuration
+    selectEvent.openMenu(screen.getByRole('textbox', {name: 'Notify'}));
+    const memberOption = screen.getByRole('menuitemcheckbox', {name: member.user?.name});
+    expect(memberOption).toBeChecked();
+    await userEvent.keyboard('{Escape}');
+
+    const submitMock = MockApiClient.addMockResponse({
+      url: apiEndpont,
+      method: 'POST',
+    });
+
+    // Monitor form is not submitable until something is changed
+    const submitButton = screen.getByRole('button', {name: 'Edit Monitor'});
+    expect(submitButton).toBeDisabled();
+
+    // Change Failure Tolerance
+    await userEvent.clear(screen.getByRole('spinbutton', {name: 'Failure Tolerance'}));
+    await userEvent.type(
+      screen.getByRole('spinbutton', {name: 'Failure Tolerance'}),
+      '10'
+    );
+
+    await userEvent.click(submitButton);
+
+    // XXX(epurkhiser): When the values are loaded directly from the
+    // monitor they come in as numbers, when changed via the toggles they
+    // are translated to strings :(
+    const config = {
+      max_runtime: monitor.config.max_runtime,
+      checkin_margin: monitor.config.checkin_margin,
+      recovery_threshold: monitor.config.recovery_threshold,
+      schedule: monitor.config.schedule,
+      schedule_type: monitor.config.schedule_type,
+      timezone: monitor.config.timezone,
+      failure_issue_threshold: '10',
+    };
+
+    const alertRule = {
+      environment: undefined,
+      targets: [{targetIdentifier: 1, targetType: 'Member'}],
+    };
+
+    expect(submitMock).toHaveBeenCalledWith(
+      expect.anything(),
+      expect.objectContaining({
+        data: {
+          name: monitor.name,
+          slug: monitor.slug,
+          project: monitor.project.slug,
+          type: 'cron_job',
+          config,
+          alertRule,
+        },
+      })
+    );
+  });
+});

+ 9 - 1
static/app/views/monitors/components/monitorForm.tsx

@@ -208,7 +208,7 @@ function MonitorForm({
 
   const selectedProjectId = selection.projects[0];
   const selectedProject = selectedProjectId
-    ? projects.find(p => p.id === selectedProjectId + '')
+    ? projects.find(p => p.id === selectedProjectId.toString())
     : null;
 
   const isSuperuser = isActiveSuperuser();
@@ -261,6 +261,7 @@ function MonitorForm({
         <InputGroup>
           <StyledTextField
             name="name"
+            aria-label={t('Name')}
             placeholder={t('My Cron Job')}
             required
             stacked
@@ -269,6 +270,7 @@ function MonitorForm({
           {monitor && (
             <StyledTextField
               name="slug"
+              aria-label={t('Slug')}
               help={tct(
                 'The [strong:monitor-slug] is used to uniquely identify your monitor within your organization. Changing this slug will require updates to any instrumented check-in calls.',
                 {strong: <strong />}
@@ -282,6 +284,7 @@ function MonitorForm({
           )}
           <StyledSentryProjectSelectorField
             name="project"
+            aria-label={t('Project')}
             projects={filteredProjects}
             placeholder={t('Choose Project')}
             disabled={!!monitor}
@@ -308,6 +311,7 @@ function MonitorForm({
           )}
           <StyledSelectField
             name="config.schedule_type"
+            aria-label={t('Schedule Type')}
             options={SCHEDULE_OPTIONS}
             defaultValue={ScheduleType.CRONTAB}
             orientInline
@@ -331,6 +335,7 @@ function MonitorForm({
                   <MultiColumnInput columns="1fr 2fr">
                     <StyledTextField
                       name="config.schedule"
+                      aria-label={t('Crontab Schedule')}
                       placeholder="* * * * *"
                       defaultValue={DEFAULT_CRONTAB}
                       css={{input: {fontFamily: commonTheme.text.familyMono}}}
@@ -340,6 +345,7 @@ function MonitorForm({
                     />
                     <StyledSelectField
                       name="config.timezone"
+                      aria-label={t('Timezone')}
                       defaultValue="UTC"
                       options={timezoneOptions}
                       required
@@ -356,6 +362,7 @@ function MonitorForm({
                     <LabelText>{t('Every')}</LabelText>
                     <StyledNumberField
                       name="config.schedule.frequency"
+                      aria-label={t('Interval Frequency')}
                       placeholder="e.g. 1"
                       defaultValue="1"
                       min={1}
@@ -365,6 +372,7 @@ function MonitorForm({
                     />
                     <StyledSelectField
                       name="config.schedule.interval"
+                      aria-label={t('Interval Type')}
                       options={getScheduleIntervals(
                         Number(form.current.getValue('config.schedule.frequency') ?? 1)
                       )}

+ 1 - 0
static/app/views/monitors/types.tsx

@@ -77,6 +77,7 @@ export interface MonitorEnvironment {
   lastCheckIn: string | null;
   name: string;
   nextCheckIn: string | null;
+  nextCheckInLatest: string | null;
   status: MonitorStatus;
 }