index.spec.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402
  1. import {browserHistory} from 'react-router';
  2. import selectEvent from 'react-select-event';
  3. import {GroupingConfigs} from 'sentry-fixture/groupingConfigs';
  4. import LocationFixture from 'sentry-fixture/locationFixture';
  5. import {Organization} from 'sentry-fixture/organization';
  6. import RouterContextFixture from 'sentry-fixture/routerContextFixture';
  7. import {
  8. act,
  9. fireEvent,
  10. render,
  11. renderGlobalModal,
  12. screen,
  13. userEvent,
  14. waitFor,
  15. } from 'sentry-test/reactTestingLibrary';
  16. import {addErrorMessage, addSuccessMessage} from 'sentry/actionCreators/indicator';
  17. import {removePageFiltersStorage} from 'sentry/components/organizations/pageFilters/persistence';
  18. import ProjectsStore from 'sentry/stores/projectsStore';
  19. import ProjectContext from 'sentry/views/projects/projectContext';
  20. import ProjectGeneralSettings from 'sentry/views/settings/projectGeneralSettings';
  21. jest.mock('sentry/actionCreators/indicator');
  22. jest.mock('sentry/components/organizations/pageFilters/persistence');
  23. function getField(role, name) {
  24. return screen.getByRole(role, {name});
  25. }
  26. describe('projectGeneralSettings', function () {
  27. const org = Organization();
  28. const project = TestStubs.Project({
  29. subjectPrefix: '[my-org]',
  30. resolveAge: 48,
  31. allowedDomains: ['example.com', 'https://example.com'],
  32. scrapeJavaScript: true,
  33. securityToken: 'security-token',
  34. securityTokenHeader: 'x-security-header',
  35. verifySSL: true,
  36. });
  37. const groupingConfigs = GroupingConfigs();
  38. let routerContext;
  39. let putMock;
  40. const router = TestStubs.router();
  41. const routerProps = {
  42. location: LocationFixture(),
  43. routes: router.routes,
  44. route: router.routes[0],
  45. router,
  46. routeParams: router.params,
  47. };
  48. beforeEach(function () {
  49. jest.spyOn(window.location, 'assign');
  50. routerContext = RouterContextFixture([
  51. {
  52. router: TestStubs.router({
  53. params: {
  54. projectId: project.slug,
  55. },
  56. }),
  57. },
  58. ]);
  59. MockApiClient.clearMockResponses();
  60. MockApiClient.addMockResponse({
  61. url: `/organizations/${org.slug}/grouping-configs/`,
  62. method: 'GET',
  63. body: groupingConfigs,
  64. });
  65. MockApiClient.addMockResponse({
  66. url: `/projects/${org.slug}/${project.slug}/`,
  67. method: 'GET',
  68. body: project,
  69. });
  70. MockApiClient.addMockResponse({
  71. url: `/projects/${org.slug}/${project.slug}/environments/`,
  72. method: 'GET',
  73. body: [],
  74. });
  75. MockApiClient.addMockResponse({
  76. url: `/organizations/${org.slug}/users/`,
  77. method: 'GET',
  78. body: [],
  79. });
  80. });
  81. afterEach(function () {
  82. MockApiClient.clearMockResponses();
  83. jest.clearAllMocks();
  84. jest.restoreAllMocks();
  85. });
  86. it('renders form fields', function () {
  87. render(
  88. <ProjectGeneralSettings {...routerProps} params={{projectId: project.slug}} />
  89. );
  90. expect(getField('textbox', 'Name')).toHaveValue('Project Name');
  91. expect(getField('textbox', 'Subject Prefix')).toHaveValue('[my-org]');
  92. // Step 19 of the auto resolve slider equates to 48 hours. This is
  93. // different from thee actual field value (which will be 48)
  94. expect(getField('slider', 'Auto Resolve')).toHaveValue('19');
  95. expect(getField('textbox', 'Allowed Domains')).toHaveValue(
  96. 'example.com\nhttps://example.com'
  97. );
  98. expect(getField('checkbox', 'Enable JavaScript source fetching')).toBeChecked();
  99. expect(getField('textbox', 'Security Token')).toHaveValue('security-token');
  100. expect(getField('textbox', 'Security Token Header')).toHaveValue('x-security-header');
  101. expect(getField('checkbox', 'Verify TLS/SSL')).toBeChecked();
  102. });
  103. it('disables scrapeJavaScript when equivalent org setting is false', function () {
  104. routerContext.context.organization.scrapeJavaScript = false;
  105. render(
  106. <ProjectGeneralSettings {...routerProps} params={{projectId: project.slug}} />,
  107. {
  108. context: routerContext,
  109. }
  110. );
  111. expect(getField('checkbox', 'Enable JavaScript source fetching')).toBeDisabled();
  112. expect(getField('checkbox', 'Enable JavaScript source fetching')).not.toBeChecked();
  113. });
  114. it('project admins can remove project', async function () {
  115. const deleteMock = MockApiClient.addMockResponse({
  116. url: `/projects/${org.slug}/${project.slug}/`,
  117. method: 'DELETE',
  118. });
  119. render(
  120. <ProjectGeneralSettings {...routerProps} params={{projectId: project.slug}} />
  121. );
  122. await userEvent.click(screen.getByRole('button', {name: 'Remove Project'}));
  123. // Click confirmation button
  124. renderGlobalModal();
  125. await userEvent.click(screen.getByTestId('confirm-button'));
  126. expect(deleteMock).toHaveBeenCalled();
  127. expect(removePageFiltersStorage).toHaveBeenCalledWith('org-slug');
  128. });
  129. it('project admins can transfer project', async function () {
  130. const deleteMock = MockApiClient.addMockResponse({
  131. url: `/projects/${org.slug}/${project.slug}/transfer/`,
  132. method: 'POST',
  133. });
  134. render(
  135. <ProjectGeneralSettings {...routerProps} params={{projectId: project.slug}} />
  136. );
  137. await userEvent.click(screen.getByRole('button', {name: 'Transfer Project'}));
  138. // Click confirmation button
  139. renderGlobalModal();
  140. await userEvent.type(getField('textbox', 'Organization Owner'), 'billy@sentry.io');
  141. await userEvent.click(screen.getByTestId('confirm-button'));
  142. await waitFor(() =>
  143. expect(deleteMock).toHaveBeenCalledWith(
  144. `/projects/${org.slug}/${project.slug}/transfer/`,
  145. expect.objectContaining({
  146. method: 'POST',
  147. data: {
  148. email: 'billy@sentry.io',
  149. },
  150. })
  151. )
  152. );
  153. expect(addSuccessMessage).toHaveBeenCalled();
  154. });
  155. it('handles errors on transfer project', async function () {
  156. const deleteMock = MockApiClient.addMockResponse({
  157. url: `/projects/${org.slug}/${project.slug}/transfer/`,
  158. method: 'POST',
  159. statusCode: 400,
  160. body: {detail: 'An organization owner could not be found'},
  161. });
  162. render(
  163. <ProjectGeneralSettings {...routerProps} params={{projectId: project.slug}} />
  164. );
  165. await userEvent.click(screen.getByRole('button', {name: 'Transfer Project'}));
  166. // Click confirmation button
  167. renderGlobalModal();
  168. await userEvent.type(getField('textbox', 'Organization Owner'), 'billy@sentry.io');
  169. await userEvent.click(screen.getByTestId('confirm-button'));
  170. await waitFor(() => expect(deleteMock).toHaveBeenCalled());
  171. expect(addSuccessMessage).not.toHaveBeenCalled();
  172. expect(addErrorMessage).toHaveBeenCalled();
  173. // Check the error message
  174. const {container} = render((addErrorMessage as jest.Mock).mock.calls[0][0]);
  175. expect(container).toHaveTextContent(
  176. 'Error transferring project-slug. An organization owner could not be found'
  177. );
  178. });
  179. it('displays transfer/remove message for non-admins', function () {
  180. routerContext.context.organization.access = ['org:read'];
  181. const {container} = render(
  182. <ProjectGeneralSettings {...routerProps} params={{projectId: project.slug}} />,
  183. {context: routerContext}
  184. );
  185. expect(container).toHaveTextContent(
  186. 'You do not have the required permission to remove this project.'
  187. );
  188. expect(container).toHaveTextContent(
  189. 'You do not have the required permission to transfer this project.'
  190. );
  191. });
  192. it('disables the form for users without write permissions', function () {
  193. const readOnlyOrg = Organization({access: ['org:read']});
  194. routerContext.context.organization = readOnlyOrg;
  195. render(
  196. <ProjectGeneralSettings {...routerProps} params={{projectId: project.slug}} />,
  197. {
  198. context: routerContext,
  199. organization: readOnlyOrg,
  200. }
  201. );
  202. // no textboxes are enabled
  203. screen.queryAllByRole('textbox').forEach(textbox => expect(textbox).toBeDisabled());
  204. expect(screen.getByTestId('project-permission-alert')).toBeInTheDocument();
  205. });
  206. it('changing project platform updates ProjectsStore', async function () {
  207. const params = {projectId: project.slug};
  208. ProjectsStore.loadInitialData([project]);
  209. putMock = MockApiClient.addMockResponse({
  210. url: `/projects/${org.slug}/${project.slug}/`,
  211. method: 'PUT',
  212. body: {
  213. ...project,
  214. platform: 'javascript',
  215. },
  216. });
  217. render(
  218. <ProjectContext projectSlug={project.slug}>
  219. <ProjectGeneralSettings
  220. {...routerProps}
  221. routes={[]}
  222. location={routerContext.context.location}
  223. params={params}
  224. />
  225. </ProjectContext>,
  226. {context: routerContext}
  227. );
  228. const platformSelect = await screen.findByRole('textbox', {name: 'Platform'});
  229. await selectEvent.select(platformSelect, ['React']);
  230. expect(putMock).toHaveBeenCalled();
  231. // updates ProjectsStore
  232. expect(ProjectsStore.getById('2')!.platform).toBe('javascript');
  233. });
  234. it('changing name updates ProjectsStore', async function () {
  235. const params = {projectId: project.slug};
  236. ProjectsStore.loadInitialData([project]);
  237. putMock = MockApiClient.addMockResponse({
  238. url: `/projects/${org.slug}/${project.slug}/`,
  239. method: 'PUT',
  240. body: {
  241. ...project,
  242. slug: 'new-project',
  243. },
  244. });
  245. render(
  246. <ProjectContext projectSlug={project.slug}>
  247. <ProjectGeneralSettings
  248. {...routerProps}
  249. routes={[]}
  250. location={routerContext.context.location}
  251. params={params}
  252. />
  253. </ProjectContext>,
  254. {context: routerContext}
  255. );
  256. await userEvent.type(
  257. await screen.findByRole('textbox', {name: 'Name'}),
  258. 'New Project'
  259. );
  260. // Slug does not save on blur
  261. expect(putMock).not.toHaveBeenCalled();
  262. // Saves when clicking save
  263. await userEvent.click(screen.getByRole('button', {name: 'Save'}));
  264. // Redirects the user
  265. await waitFor(() => expect(browserHistory.replace).toHaveBeenCalled());
  266. expect(ProjectsStore.getById('2')!.slug).toBe('new-project');
  267. });
  268. describe('Non-"save on blur" Field', function () {
  269. beforeEach(function () {
  270. const params = {projectId: project.slug};
  271. ProjectsStore.loadInitialData([project]);
  272. putMock = MockApiClient.addMockResponse({
  273. url: `/projects/${org.slug}/${project.slug}/`,
  274. method: 'PUT',
  275. body: {
  276. ...project,
  277. slug: 'new-project',
  278. },
  279. });
  280. render(
  281. <ProjectContext projectSlug={project.slug}>
  282. <ProjectGeneralSettings
  283. {...routerProps}
  284. routes={[]}
  285. location={routerContext.context.location}
  286. params={params}
  287. />
  288. </ProjectContext>,
  289. {context: routerContext}
  290. );
  291. });
  292. it('can cancel unsaved changes for a field', async function () {
  293. expect(screen.queryByRole('button', {name: 'Cancel'})).not.toBeInTheDocument();
  294. const autoResolveSlider = getField('slider', 'Auto Resolve');
  295. expect(autoResolveSlider).toHaveValue('19');
  296. // Change value
  297. fireEvent.change(autoResolveSlider, {target: {value: '12'}});
  298. expect(autoResolveSlider).toHaveValue('12');
  299. // Click cancel
  300. await userEvent.click(screen.getByRole('button', {name: 'Cancel'}));
  301. // Cancel row should disappear
  302. expect(screen.queryByRole('button', {name: 'Cancel'})).not.toBeInTheDocument();
  303. // Value should be reverted
  304. expect(autoResolveSlider).toHaveValue('19');
  305. // PUT should not be called
  306. expect(putMock).not.toHaveBeenCalled();
  307. });
  308. it('saves when value is changed and "Save" clicked', async function () {
  309. expect(screen.queryByRole('button', {name: 'Save'})).not.toBeInTheDocument();
  310. const autoResolveSlider = getField('slider', 'Auto Resolve');
  311. expect(autoResolveSlider).toHaveValue('19');
  312. // Change value
  313. fireEvent.change(autoResolveSlider, {target: {value: '12'}});
  314. expect(autoResolveSlider).toHaveValue('12');
  315. // Should not have put mock called yet
  316. expect(putMock).not.toHaveBeenCalled();
  317. // Click "Save"
  318. await userEvent.click(screen.getByRole('button', {name: 'Save'}));
  319. await act(tick);
  320. // API endpoint should have been called
  321. expect(putMock).toHaveBeenCalledWith(
  322. expect.anything(),
  323. expect.objectContaining({
  324. data: {
  325. resolveAge: 12,
  326. },
  327. })
  328. );
  329. expect(screen.queryByRole('button', {name: 'Save'})).not.toBeInTheDocument();
  330. });
  331. });
  332. });