InventoryPageDeepLink.test.tsx 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294
  1. /**
  2. * Tests for the ?spool= deep-link flow in InventoryPage.
  3. *
  4. * Three scenarios:
  5. * 1. Spool is already in the loaded list → modal opens immediately.
  6. * 2. Spool is not in list → targeted API fetch succeeds → modal opens.
  7. * 3. Spool is not found (404) → error toast shown, param removed from URL.
  8. */
  9. import { describe, it, expect, beforeEach, afterEach } from 'vitest';
  10. import { screen, waitFor } from '@testing-library/react';
  11. import { render } from '../utils';
  12. import InventoryPageRouter from '../../pages/InventoryPage';
  13. import { http, HttpResponse } from 'msw';
  14. import { server } from '../mocks/server';
  15. // Minimal spool fixture shared across scenarios
  16. const BASE_SPOOL = {
  17. id: 42,
  18. material: 'PLA',
  19. subtype: 'Basic',
  20. brand: 'Bambu Lab',
  21. color_name: 'Red',
  22. rgba: 'FF0000FF',
  23. label_weight: 1000,
  24. core_weight: 250,
  25. weight_used: 100,
  26. slicer_filament: null,
  27. slicer_filament_name: null,
  28. nozzle_temp_min: 220,
  29. nozzle_temp_max: 240,
  30. note: null,
  31. added_full: null,
  32. last_used: null,
  33. encode_time: null,
  34. tag_uid: null,
  35. tray_uuid: null,
  36. data_origin: null,
  37. tag_type: null,
  38. archived_at: null,
  39. created_at: '2025-01-01T00:00:00Z',
  40. updated_at: '2025-01-01T00:00:00Z',
  41. k_profiles: [],
  42. cost_per_kg: null,
  43. last_scale_weight: null,
  44. last_weighed_at: null,
  45. storage_location: null,
  46. weight_locked: false,
  47. };
  48. const MOCK_SETTINGS = {
  49. auto_archive: false,
  50. save_thumbnails: false,
  51. capture_finish_photo: false,
  52. default_filament_cost: 25.0,
  53. currency: 'USD',
  54. energy_cost_per_kwh: 0.15,
  55. energy_tracking_mode: 'total',
  56. spoolman_enabled: false,
  57. spoolman_url: '',
  58. spoolman_sync_mode: 'auto',
  59. spoolman_disable_weight_sync: false,
  60. spoolman_report_partial_usage: true,
  61. check_updates: false,
  62. check_printer_firmware: false,
  63. include_beta_updates: false,
  64. language: 'en',
  65. notification_language: 'en',
  66. bed_cooled_threshold: 35,
  67. ams_humidity_good: 40,
  68. ams_humidity_fair: 60,
  69. ams_temp_good: 28,
  70. ams_temp_fair: 35,
  71. ams_history_retention_days: 30,
  72. per_printer_mapping_expanded: false,
  73. date_format: 'system',
  74. time_format: 'system',
  75. default_printer_id: null,
  76. virtual_printer_enabled: false,
  77. virtual_printer_access_code: '',
  78. virtual_printer_mode: 'archive',
  79. dark_style: 'classic',
  80. dark_background: 'neutral',
  81. dark_accent: 'green',
  82. light_style: 'classic',
  83. light_background: 'neutral',
  84. light_accent: 'green',
  85. ftp_retry_enabled: true,
  86. ftp_retry_count: 3,
  87. ftp_retry_delay: 2,
  88. ftp_timeout: 30,
  89. mqtt_enabled: false,
  90. mqtt_broker: '',
  91. mqtt_port: 1883,
  92. mqtt_username: '',
  93. mqtt_password: '',
  94. mqtt_topic_prefix: 'bambuddy',
  95. mqtt_use_tls: false,
  96. external_url: '',
  97. ha_enabled: false,
  98. ha_url: '',
  99. ha_token: '',
  100. ha_url_from_env: false,
  101. ha_token_from_env: false,
  102. ha_env_managed: false,
  103. library_archive_mode: 'ask',
  104. library_disk_warning_gb: 5.0,
  105. camera_view_mode: 'window',
  106. preferred_slicer: 'bambu_studio',
  107. prometheus_enabled: false,
  108. prometheus_token: '',
  109. low_stock_threshold: 20.0,
  110. };
  111. function setupCommonHandlers(spoolList: object[]) {
  112. server.use(
  113. http.get('/api/v1/settings/', () => HttpResponse.json(MOCK_SETTINGS)),
  114. // getSpoolmanSettings calls /api/v1/settings/spoolman (not /api/v1/spoolman/settings)
  115. http.get('/api/v1/settings/spoolman', () =>
  116. HttpResponse.json({ spoolman_enabled: 'false', spoolman_url: '', spoolman_sync_mode: 'auto', spoolman_disable_weight_sync: 'false', spoolman_report_partial_usage: 'true' })
  117. ),
  118. http.get('/api/v1/inventory/spools', () => HttpResponse.json(spoolList)),
  119. http.get('/api/v1/inventory/spools/:id/usage', () => HttpResponse.json([])),
  120. http.get('/api/v1/inventory/assignments', () => HttpResponse.json([])),
  121. http.get('/api/v1/inventory/catalog', () => HttpResponse.json([])),
  122. // Deep-link flows open SpoolFormModal, which fires off these fetches the
  123. // moment it mounts. Without handlers MSW would passthrough to the real
  124. // network (ECONNREFUSED); the rejected fetch then resolves after the
  125. // test environment is torn down, surfacing as an unhandled rejection
  126. // ("window is not defined") in the modal's setState finally.
  127. http.get('/api/v1/cloud/status', () =>
  128. HttpResponse.json({ is_authenticated: false })
  129. ),
  130. http.get('/api/v1/cloud/local-presets', () =>
  131. HttpResponse.json({ filament: [], printer: [], process: [] })
  132. ),
  133. http.get('/api/v1/local-presets/', () =>
  134. HttpResponse.json({ filament: [], printer: [], process: [] })
  135. ),
  136. http.get('/api/v1/cloud/builtin-filaments', () => HttpResponse.json([])),
  137. http.get('/api/v1/inventory/color-catalog', () => HttpResponse.json([])),
  138. http.get('/api/v1/inventory/colors', () => HttpResponse.json([])),
  139. http.get('/api/v1/inventory/spool-catalog', () => HttpResponse.json([])),
  140. http.get('/api/v1/inventory/locations', () => HttpResponse.json([])),
  141. http.get('/api/v1/printers/', () => HttpResponse.json([])),
  142. );
  143. }
  144. describe('InventoryPage - deep-link ?spool= flow', () => {
  145. const originalLocation = window.location.href;
  146. afterEach(() => {
  147. // Restore URL after each test
  148. window.history.replaceState({}, '', originalLocation);
  149. });
  150. describe('scenario 1: spool is already in the loaded list', () => {
  151. beforeEach(() => {
  152. window.history.pushState({}, '', '/?spool=42');
  153. setupCommonHandlers([BASE_SPOOL]);
  154. });
  155. it('removes ?spool= param from URL after handling', async () => {
  156. render(<InventoryPageRouter />);
  157. await waitFor(() => {
  158. expect(window.location.search).not.toContain('spool=42');
  159. });
  160. });
  161. it('opens the edit modal for the linked spool', async () => {
  162. render(<InventoryPageRouter />);
  163. // The SpoolFormModal should open — it renders material name in a heading or field
  164. await waitFor(() => {
  165. // Modal is open when the spool form inputs are visible
  166. expect(screen.getAllByText(/PLA/i).length).toBeGreaterThan(0);
  167. });
  168. });
  169. });
  170. describe('scenario 2: targeted fetch (spool not in initial list)', () => {
  171. beforeEach(() => {
  172. window.history.pushState({}, '', '/?spool=42');
  173. // List is empty; single-spool fetch returns the spool
  174. setupCommonHandlers([]);
  175. server.use(
  176. http.get('/api/v1/inventory/spools/:id', ({ params }) => {
  177. if (Number(params.id) === 42) {
  178. return HttpResponse.json(BASE_SPOOL);
  179. }
  180. return HttpResponse.json({ detail: 'Not found' }, { status: 404 });
  181. })
  182. );
  183. });
  184. it('removes ?spool= param from URL after successful targeted fetch', async () => {
  185. render(<InventoryPageRouter />);
  186. await waitFor(() => {
  187. expect(window.location.search).not.toContain('spool=42');
  188. });
  189. });
  190. });
  191. describe('scenario 3: spool not found (404)', () => {
  192. beforeEach(() => {
  193. window.history.pushState({}, '', '/?spool=9999');
  194. setupCommonHandlers([]);
  195. server.use(
  196. http.get('/api/v1/inventory/spools/:id', () =>
  197. HttpResponse.json({ detail: 'Not found' }, { status: 404 })
  198. )
  199. );
  200. });
  201. it('removes ?spool= param from URL on 404', async () => {
  202. render(<InventoryPageRouter />);
  203. await waitFor(() => {
  204. expect(window.location.search).not.toContain('spool=9999');
  205. });
  206. });
  207. it('shows an error notification when spool is not found', async () => {
  208. render(<InventoryPageRouter />);
  209. // Must render the exact i18n string for deepLinkSpoolNotFound (en: 'Spool not found').
  210. // Using findByText fails the test if the toast is absent or uses the wrong key.
  211. await screen.findByText('Spool not found');
  212. });
  213. });
  214. describe('scenario 4: targeted fetch returns 5xx server error', () => {
  215. beforeEach(() => {
  216. window.history.pushState({}, '', '/?spool=42');
  217. setupCommonHandlers([]);
  218. server.use(
  219. http.get('/api/v1/inventory/spools/:id', () =>
  220. HttpResponse.json({ detail: 'Internal Server Error' }, { status: 500 })
  221. )
  222. );
  223. });
  224. it('shows the deepLinkFetchFailed toast on 5xx', async () => {
  225. render(<InventoryPageRouter />);
  226. // The deep-link query has a custom retry callback (up to 2 retries with
  227. // exponential backoff) that overrides the test QueryClient's retry:false.
  228. // Allow up to 6 s for the retries to exhaust before the toast appears.
  229. await screen.findByText('Could not load spool — try again', {}, { timeout: 6000 });
  230. });
  231. });
  232. describe('scenario 5 (T-Gap 8): deep-link works in Spoolman mode', () => {
  233. beforeEach(() => {
  234. window.history.pushState({}, '', '/?spool=42');
  235. // Inherit common modal stubs (cloud/status, colors, presets, etc.) — then
  236. // override the bits that flip into Spoolman mode. Runtime handlers added
  237. // last win, so the spoolman_enabled: true settings response shadows the
  238. // common one.
  239. setupCommonHandlers([BASE_SPOOL]);
  240. server.use(
  241. http.get('/api/v1/settings/', () =>
  242. HttpResponse.json({ ...MOCK_SETTINGS, spoolman_enabled: true, spoolman_url: 'http://spoolman.local:7912' })
  243. ),
  244. http.get('/api/v1/settings/spoolman', () =>
  245. HttpResponse.json({ spoolman_enabled: 'true', spoolman_url: 'http://spoolman.local:7912', spoolman_sync_mode: 'auto', spoolman_disable_weight_sync: 'false', spoolman_report_partial_usage: 'true' })
  246. ),
  247. // Spoolman-mode list + per-spool fetches the modal makes when editing a Spoolman spool
  248. http.get('/api/v1/spoolman/inventory/spools', () => HttpResponse.json([BASE_SPOOL])),
  249. http.get('/api/v1/spoolman/inventory/spools/:id', () => HttpResponse.json(BASE_SPOOL)),
  250. http.get('/api/v1/spoolman/inventory/slot-assignments/all', () => HttpResponse.json([])),
  251. http.get('/api/v1/spoolman/inventory/filaments', () => HttpResponse.json([])),
  252. );
  253. });
  254. it('removes ?spool= param from URL in Spoolman mode', async () => {
  255. render(<InventoryPageRouter />);
  256. await waitFor(() => {
  257. expect(window.location.search).not.toContain('spool=42');
  258. });
  259. });
  260. it('opens the edit modal for the linked local spool in Spoolman mode', async () => {
  261. render(<InventoryPageRouter />);
  262. await waitFor(() => {
  263. expect(screen.getAllByText(/PLA/i).length).toBeGreaterThan(0);
  264. });
  265. });
  266. });
  267. });