index.spec.tsx 12 KB

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