MakerworldPage.test.tsx 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365
  1. /**
  2. * Tests for the MakerworldPage URL-paste flow.
  3. *
  4. * Covers: status-driven warning banner, resolve round-trip populates design +
  5. * plate list, "Already imported" badge appears when the backend reports prior
  6. * imports, button labels (Save / Save & Slice in <slicer>), URL-change detection,
  7. * inline imported-plate action buttons, the Recent imports sidebar, and
  8. * DOMPurify sanitising of user-authored summary HTML.
  9. */
  10. import { describe, it, expect, afterEach } from 'vitest';
  11. import { screen, waitFor } from '@testing-library/react';
  12. import userEvent from '@testing-library/user-event';
  13. import { http, HttpResponse } from 'msw';
  14. import { render } from '../utils';
  15. import { server } from '../mocks/server';
  16. import { MakerworldPage } from '../../pages/MakerworldPage';
  17. const statusNoToken = { has_cloud_token: false, can_download: false };
  18. const statusWithToken = { has_cloud_token: true, can_download: true };
  19. function resolveResponse(overrides: Partial<Record<string, unknown>> = {}) {
  20. return {
  21. model_id: 1400373,
  22. profile_id: 1452154,
  23. design: {
  24. id: 1400373,
  25. title: 'Seed Starter',
  26. designCreator: { name: 'Meyui', avatar: '' },
  27. coverUrl: 'https://makerworld.bblmw.com/img/cover.png',
  28. license: 'Standard',
  29. downloadCount: 1234,
  30. summary: '<p>A seed starter</p>',
  31. },
  32. instances: [
  33. {
  34. id: 1452154,
  35. profileId: 298919107,
  36. title: '9 cells',
  37. cover: '',
  38. materialCnt: 1,
  39. needAms: false,
  40. downloadCount: 500,
  41. },
  42. {
  43. id: 1452158,
  44. profileId: 298919564,
  45. title: '12 cells',
  46. cover: '',
  47. materialCnt: 2,
  48. needAms: true,
  49. downloadCount: 120,
  50. },
  51. ],
  52. already_imported_library_ids: [],
  53. ...overrides,
  54. };
  55. }
  56. // Helper: seed all the handlers a "signed-in, happy-path" render needs.
  57. // Individual tests layer extra handlers on top via ``server.use``.
  58. function useAuthedHandlers(opts: {
  59. slicer?: 'bambu_studio' | 'orcaslicer';
  60. openInSlicer?: 'bambu_studio' | 'orcaslicer' | null;
  61. useSlicerApi?: boolean;
  62. recent?: Array<Record<string, unknown>>;
  63. } = {}) {
  64. const slicer = opts.slicer ?? 'bambu_studio';
  65. const recent = opts.recent ?? [];
  66. server.use(
  67. http.get('*/makerworld/status', () => HttpResponse.json(statusWithToken)),
  68. http.get('*/makerworld/recent-imports', () => HttpResponse.json(recent)),
  69. http.get('*/library/folders', () => HttpResponse.json([])),
  70. http.get('*/settings/', () =>
  71. HttpResponse.json({
  72. auto_archive: true,
  73. save_thumbnails: true,
  74. preferred_slicer: slicer,
  75. ...(opts.openInSlicer !== undefined ? { open_in_slicer: opts.openInSlicer } : {}),
  76. ...(opts.useSlicerApi !== undefined ? { use_slicer_api: opts.useSlicerApi } : {}),
  77. }),
  78. ),
  79. );
  80. }
  81. afterEach(() => server.resetHandlers());
  82. describe('MakerworldPage', () => {
  83. it('renders the sign-in-required banner when no Bambu Cloud token is stored', async () => {
  84. server.use(
  85. http.get('*/makerworld/status', () => HttpResponse.json(statusNoToken)),
  86. );
  87. render(<MakerworldPage />);
  88. expect(await screen.findByText(/Bambu Cloud sign-in required/i)).toBeInTheDocument();
  89. });
  90. it('hides the sign-in banner when a cloud token is present', async () => {
  91. useAuthedHandlers();
  92. render(<MakerworldPage />);
  93. // Page header is always visible; wait for status to settle
  94. await screen.findByRole('heading', { name: 'MakerWorld' });
  95. await waitFor(() => {
  96. expect(screen.queryByText(/Bambu Cloud sign-in required/i)).not.toBeInTheDocument();
  97. });
  98. });
  99. it('renders design + plate list after the user pastes a URL', async () => {
  100. useAuthedHandlers();
  101. server.use(
  102. http.post('*/makerworld/resolve', async ({ request }) => {
  103. const body = (await request.json()) as { url: string };
  104. expect(body.url).toContain('makerworld.com/en/models/1400373');
  105. return HttpResponse.json(resolveResponse());
  106. }),
  107. );
  108. render(<MakerworldPage />);
  109. const input = await screen.findByPlaceholderText(/https:\/\/makerworld\.com/i);
  110. await userEvent.type(input, 'https://makerworld.com/en/models/1400373-slug#profileId-1452154');
  111. await userEvent.click(screen.getByRole('button', { name: /Resolve/i }));
  112. expect(await screen.findByText('Seed Starter')).toBeInTheDocument();
  113. expect(screen.getByText('9 cells')).toBeInTheDocument();
  114. expect(screen.getByText('12 cells')).toBeInTheDocument();
  115. });
  116. it('shows the "Already in library" badge when backend reports prior imports', async () => {
  117. useAuthedHandlers();
  118. server.use(
  119. http.post('*/makerworld/resolve', () =>
  120. HttpResponse.json(resolveResponse({ already_imported_library_ids: [42] })),
  121. ),
  122. );
  123. render(<MakerworldPage />);
  124. await userEvent.type(
  125. await screen.findByPlaceholderText(/https:\/\/makerworld\.com/i),
  126. 'https://makerworld.com/en/models/1400373',
  127. );
  128. await userEvent.click(screen.getByRole('button', { name: /Resolve/i }));
  129. expect(await screen.findByText(/Already in library/i)).toBeInTheDocument();
  130. });
  131. it('labels the per-plate import button "Save" (not "Import")', async () => {
  132. useAuthedHandlers();
  133. server.use(
  134. http.post('*/makerworld/resolve', () => HttpResponse.json(resolveResponse())),
  135. );
  136. render(<MakerworldPage />);
  137. await userEvent.type(
  138. await screen.findByPlaceholderText(/https:\/\/makerworld\.com/i),
  139. 'https://makerworld.com/en/models/1400373',
  140. );
  141. await userEvent.click(screen.getByRole('button', { name: /Resolve/i }));
  142. await screen.findByText('Seed Starter');
  143. // Two plates → two Save buttons.
  144. const saveButtons = await screen.findAllByRole('button', { name: /^Save$/ });
  145. expect(saveButtons.length).toBe(2);
  146. });
  147. it('interpolates the slicer name into the slice button (Bambu Studio by default)', async () => {
  148. useAuthedHandlers({ slicer: 'bambu_studio' });
  149. server.use(
  150. http.post('*/makerworld/resolve', () => HttpResponse.json(resolveResponse())),
  151. );
  152. render(<MakerworldPage />);
  153. await userEvent.type(
  154. await screen.findByPlaceholderText(/https:\/\/makerworld\.com/i),
  155. 'https://makerworld.com/en/models/1400373',
  156. );
  157. await userEvent.click(screen.getByRole('button', { name: /Resolve/i }));
  158. const sliceButtons = await screen.findAllByRole('button', {
  159. name: /Save & Slice in Bambu Studio/,
  160. });
  161. expect(sliceButtons.length).toBe(2);
  162. });
  163. it('interpolates OrcaSlicer when that is the configured preferred slicer', async () => {
  164. useAuthedHandlers({ slicer: 'orcaslicer' });
  165. server.use(
  166. http.post('*/makerworld/resolve', () => HttpResponse.json(resolveResponse())),
  167. );
  168. render(<MakerworldPage />);
  169. await userEvent.type(
  170. await screen.findByPlaceholderText(/https:\/\/makerworld\.com/i),
  171. 'https://makerworld.com/en/models/1400373',
  172. );
  173. await userEvent.click(screen.getByRole('button', { name: /Resolve/i }));
  174. const sliceButtons = await screen.findAllByRole('button', {
  175. name: /Save & Slice in OrcaSlicer/,
  176. });
  177. expect(sliceButtons.length).toBe(2);
  178. });
  179. it('prefers the open_in_slicer override over preferred_slicer for the desktop handoff', async () => {
  180. // The URI-handoff label resolves via resolveDesktopSlicer: open_in_slicer
  181. // beats preferred_slicer (#1329) when the slicer API is off.
  182. useAuthedHandlers({ slicer: 'bambu_studio', openInSlicer: 'orcaslicer' });
  183. server.use(
  184. http.post('*/makerworld/resolve', () => HttpResponse.json(resolveResponse())),
  185. );
  186. render(<MakerworldPage />);
  187. await userEvent.type(
  188. await screen.findByPlaceholderText(/https:\/\/makerworld\.com/i),
  189. 'https://makerworld.com/en/models/1400373',
  190. );
  191. await userEvent.click(screen.getByRole('button', { name: /Resolve/i }));
  192. const sliceButtons = await screen.findAllByRole('button', {
  193. name: /Save & Slice in OrcaSlicer/,
  194. });
  195. expect(sliceButtons.length).toBe(2);
  196. });
  197. it('keeps preferred_slicer for the in-app API slice label, ignoring open_in_slicer', async () => {
  198. // With the slicer API on, the sidecar drives the button, so the
  199. // open_in_slicer desktop override must not leak into the label.
  200. useAuthedHandlers({ slicer: 'bambu_studio', openInSlicer: 'orcaslicer', useSlicerApi: true });
  201. server.use(
  202. http.post('*/makerworld/resolve', () => HttpResponse.json(resolveResponse())),
  203. );
  204. render(<MakerworldPage />);
  205. await userEvent.type(
  206. await screen.findByPlaceholderText(/https:\/\/makerworld\.com/i),
  207. 'https://makerworld.com/en/models/1400373',
  208. );
  209. await userEvent.click(screen.getByRole('button', { name: /Resolve/i }));
  210. const sliceButtons = await screen.findAllByRole('button', {
  211. name: /Save & Slice in Bambu Studio/,
  212. });
  213. expect(sliceButtons.length).toBe(2);
  214. });
  215. it('clears the resolved preview when the URL input is edited after resolve', async () => {
  216. useAuthedHandlers();
  217. server.use(
  218. http.post('*/makerworld/resolve', () => HttpResponse.json(resolveResponse())),
  219. );
  220. render(<MakerworldPage />);
  221. const input = await screen.findByPlaceholderText(/https:\/\/makerworld\.com/i);
  222. await userEvent.type(input, 'https://makerworld.com/en/models/1400373');
  223. await userEvent.click(screen.getByRole('button', { name: /Resolve/i }));
  224. expect(await screen.findByText('Seed Starter')).toBeInTheDocument();
  225. // User retypes — preview must go away so they can't accidentally submit the
  226. // previous model.
  227. await userEvent.clear(input);
  228. await userEvent.type(input, 'https://makerworld.com/en/models/9999999');
  229. await waitFor(() => {
  230. expect(screen.queryByText('Seed Starter')).not.toBeInTheDocument();
  231. });
  232. });
  233. it('renders inline imported-plate actions after a successful import', async () => {
  234. useAuthedHandlers();
  235. server.use(
  236. http.post('*/makerworld/resolve', () => HttpResponse.json(resolveResponse())),
  237. http.post('*/makerworld/import', () =>
  238. HttpResponse.json({
  239. library_file_id: 99,
  240. filename: 'benchy.3mf',
  241. folder_id: 7,
  242. profile_id: 298919107,
  243. was_existing: false,
  244. }),
  245. ),
  246. );
  247. render(<MakerworldPage />);
  248. await userEvent.type(
  249. await screen.findByPlaceholderText(/https:\/\/makerworld\.com/i),
  250. 'https://makerworld.com/en/models/1400373',
  251. );
  252. await userEvent.click(screen.getByRole('button', { name: /Resolve/i }));
  253. const saveButtons = await screen.findAllByRole('button', { name: /^Save$/ });
  254. await userEvent.click(saveButtons[0]);
  255. // Inline post-import row for that plate shows the "View in File Manager"
  256. // and the two slicer open buttons.
  257. await screen.findByRole('button', { name: /View in File Manager/i });
  258. expect(screen.getByRole('button', { name: /Open in Bambu Studio/i })).toBeInTheDocument();
  259. expect(screen.getByRole('button', { name: /Open in OrcaSlicer/i })).toBeInTheDocument();
  260. });
  261. it('renders the Recent imports sidebar when the query returns items', async () => {
  262. useAuthedHandlers({
  263. recent: [
  264. {
  265. library_file_id: 11,
  266. filename: 'first-import.3mf',
  267. folder_id: 2,
  268. thumbnail_path: null,
  269. source_url: 'https://makerworld.com/models/1#profileId-2',
  270. created_at: '2025-01-01T12:00:00',
  271. },
  272. {
  273. library_file_id: 12,
  274. filename: 'second-import.3mf',
  275. folder_id: 2,
  276. thumbnail_path: null,
  277. source_url: null,
  278. created_at: '2025-01-02T12:00:00',
  279. },
  280. ],
  281. });
  282. render(<MakerworldPage />);
  283. await screen.findByText(/Recent imports/i);
  284. expect(await screen.findByText('first-import.3mf')).toBeInTheDocument();
  285. expect(screen.getByText('second-import.3mf')).toBeInTheDocument();
  286. });
  287. it('omits the Recent imports sidebar when the feed is empty', async () => {
  288. // Defensive — the CardHeader uses the same translation as a potential
  289. // inline header; only the sidebar should surface "Recent imports".
  290. useAuthedHandlers({ recent: [] });
  291. render(<MakerworldPage />);
  292. await screen.findByRole('heading', { name: 'MakerWorld' });
  293. await waitFor(() => {
  294. expect(screen.queryByText(/Recent imports/i)).not.toBeInTheDocument();
  295. });
  296. });
  297. it('sanitises the design summary HTML via DOMPurify', async () => {
  298. // User-authored summaries are not trusted; a ``<script>`` tag must be
  299. // stripped before it lands in the DOM via dangerouslySetInnerHTML.
  300. useAuthedHandlers();
  301. server.use(
  302. http.post('*/makerworld/resolve', () =>
  303. HttpResponse.json(
  304. resolveResponse({
  305. design: {
  306. ...resolveResponse().design,
  307. summary:
  308. '<p>Clean prose <b>here</b></p><script>window.__pwned = true;</script>',
  309. },
  310. }),
  311. ),
  312. ),
  313. );
  314. render(<MakerworldPage />);
  315. await userEvent.type(
  316. await screen.findByPlaceholderText(/https:\/\/makerworld\.com/i),
  317. 'https://makerworld.com/en/models/1400373',
  318. );
  319. await userEvent.click(screen.getByRole('button', { name: /Resolve/i }));
  320. await screen.findByText('Seed Starter');
  321. // Safe content survives.
  322. expect(screen.getByText(/Clean prose/i)).toBeInTheDocument();
  323. // Hostile content is stripped — DOMPurify drops <script> entirely, so the
  324. // side-effect assignment can't have run.
  325. expect((window as unknown as { __pwned?: boolean }).__pwned).toBeUndefined();
  326. // And no literal ``<script>`` text leaks into the document.
  327. expect(document.body.innerHTML).not.toContain('window.__pwned');
  328. });
  329. });