index.spec.tsx 12 KB

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