index.spec.tsx 12 KB

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