InventoryPageLowStock.test.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396
  1. /**
  2. * Tests for low stock threshold functionality in InventoryPage.
  3. *
  4. * Tests that the low stock threshold:
  5. * - Is loaded from backend settings API
  6. * - Can be updated via the UI
  7. * - Persists changes to the backend
  8. * - Does not use localStorage
  9. */
  10. import { describe, it, expect, beforeEach } from 'vitest';
  11. import { screen, waitFor } from '@testing-library/react';
  12. import userEvent from '@testing-library/user-event';
  13. import { render } from '../utils';
  14. import InventoryPageRouter from '../../pages/InventoryPage';
  15. import { http, HttpResponse } from 'msw';
  16. import { server } from '../mocks/server';
  17. const mockSettings = {
  18. auto_archive: true,
  19. save_thumbnails: true,
  20. capture_finish_photo: true,
  21. default_filament_cost: 25.0,
  22. currency: 'USD',
  23. energy_cost_per_kwh: 0.15,
  24. energy_tracking_mode: 'total',
  25. spoolman_enabled: false,
  26. spoolman_url: '',
  27. spoolman_sync_mode: 'auto',
  28. spoolman_disable_weight_sync: false,
  29. spoolman_report_partial_usage: true,
  30. check_updates: true,
  31. check_printer_firmware: true,
  32. include_beta_updates: false,
  33. language: 'en',
  34. notification_language: 'en',
  35. bed_cooled_threshold: 35,
  36. ams_humidity_good: 40,
  37. ams_humidity_fair: 60,
  38. ams_temp_good: 28,
  39. ams_temp_fair: 35,
  40. ams_history_retention_days: 30,
  41. per_printer_mapping_expanded: false,
  42. date_format: 'system',
  43. time_format: 'system',
  44. default_printer_id: null,
  45. virtual_printer_enabled: false,
  46. virtual_printer_access_code: '',
  47. virtual_printer_mode: 'immediate',
  48. dark_style: 'classic',
  49. dark_background: 'neutral',
  50. dark_accent: 'green',
  51. light_style: 'classic',
  52. light_background: 'neutral',
  53. light_accent: 'green',
  54. ftp_retry_enabled: true,
  55. ftp_retry_count: 3,
  56. ftp_retry_delay: 2,
  57. ftp_timeout: 30,
  58. mqtt_enabled: false,
  59. mqtt_broker: '',
  60. mqtt_port: 1883,
  61. mqtt_username: '',
  62. mqtt_password: '',
  63. mqtt_topic_prefix: 'bambuddy',
  64. mqtt_use_tls: false,
  65. external_url: '',
  66. ha_enabled: false,
  67. ha_url: '',
  68. ha_token: '',
  69. ha_url_from_env: false,
  70. ha_token_from_env: false,
  71. ha_env_managed: false,
  72. library_archive_mode: 'ask',
  73. library_disk_warning_gb: 5.0,
  74. camera_view_mode: 'window',
  75. preferred_slicer: 'bambu_studio',
  76. prometheus_enabled: false,
  77. prometheus_token: '',
  78. low_stock_threshold: 20.0,
  79. };
  80. const mockSpools = [
  81. {
  82. id: 1,
  83. material: 'PLA',
  84. subtype: null,
  85. brand: 'Polymaker',
  86. color_name: 'Red',
  87. rgba: 'FF0000FF',
  88. label_weight: 1000,
  89. core_weight: 250,
  90. weight_used: 900, // 10% remaining - low stock
  91. slicer_filament: null,
  92. slicer_filament_name: null,
  93. nozzle_temp_min: null,
  94. nozzle_temp_max: null,
  95. note: null,
  96. added_full: null,
  97. last_used: null,
  98. encode_time: null,
  99. tag_uid: null,
  100. tray_uuid: null,
  101. data_origin: null,
  102. tag_type: null,
  103. archived_at: null,
  104. created_at: '2025-01-01T00:00:00Z',
  105. updated_at: '2025-01-01T00:00:00Z',
  106. k_profiles: [],
  107. cost_per_kg: null,
  108. last_scale_weight: null,
  109. last_weighed_at: null,
  110. },
  111. {
  112. id: 2,
  113. material: 'PETG',
  114. subtype: null,
  115. brand: 'eSun',
  116. color_name: 'Blue',
  117. rgba: '0000FFFF',
  118. label_weight: 1000,
  119. core_weight: 250,
  120. weight_used: 200, // 80% remaining - not low stock
  121. slicer_filament: null,
  122. slicer_filament_name: null,
  123. nozzle_temp_min: null,
  124. nozzle_temp_max: null,
  125. note: null,
  126. added_full: null,
  127. last_used: null,
  128. encode_time: null,
  129. tag_uid: null,
  130. tray_uuid: null,
  131. data_origin: null,
  132. tag_type: null,
  133. archived_at: null,
  134. created_at: '2025-01-02T00:00:00Z',
  135. updated_at: '2025-01-02T00:00:00Z',
  136. k_profiles: [],
  137. cost_per_kg: null,
  138. last_scale_weight: null,
  139. last_weighed_at: null,
  140. },
  141. {
  142. id: 3,
  143. material: 'ABS',
  144. subtype: null,
  145. brand: 'Hatchbox',
  146. color_name: 'Black',
  147. rgba: '000000FF',
  148. label_weight: 1000,
  149. core_weight: 250,
  150. weight_used: 850, // 15% remaining - low stock
  151. slicer_filament: null,
  152. slicer_filament_name: null,
  153. nozzle_temp_min: null,
  154. nozzle_temp_max: null,
  155. note: null,
  156. added_full: null,
  157. last_used: null,
  158. encode_time: null,
  159. tag_uid: null,
  160. tray_uuid: null,
  161. data_origin: null,
  162. tag_type: null,
  163. archived_at: null,
  164. created_at: '2025-01-03T00:00:00Z',
  165. updated_at: '2025-01-03T00:00:00Z',
  166. k_profiles: [],
  167. cost_per_kg: null,
  168. last_scale_weight: null,
  169. last_weighed_at: null,
  170. },
  171. ];
  172. describe('InventoryPage - Low Stock Threshold', () => {
  173. beforeEach(() => {
  174. // Clear localStorage to ensure we're not relying on it
  175. localStorage.clear();
  176. server.use(
  177. http.get('/api/v1/settings/', () => {
  178. return HttpResponse.json(mockSettings);
  179. }),
  180. http.put('/api/v1/settings/', async ({ request }) => {
  181. const body = (await request.json()) as Partial<typeof mockSettings>;
  182. return HttpResponse.json({ ...mockSettings, ...body });
  183. }),
  184. http.get('/api/v1/inventory/spools', () => {
  185. return HttpResponse.json(mockSpools);
  186. }),
  187. http.get('/api/v1/inventory/assignments', () => {
  188. return HttpResponse.json([]);
  189. }),
  190. http.get('/api/v1/spoolman/settings', () => {
  191. return HttpResponse.json({ spoolman_enabled: 'false' });
  192. })
  193. );
  194. });
  195. describe('default threshold from backend', () => {
  196. it('loads the default threshold of 20% from backend settings', async () => {
  197. render(<InventoryPageRouter />);
  198. await waitFor(() => {
  199. // Find the low stock stat showing the threshold
  200. expect(screen.getByText(/< 20%/i)).toBeInTheDocument();
  201. });
  202. });
  203. it('calculates low stock count based on default threshold', async () => {
  204. render(<InventoryPageRouter />);
  205. await waitFor(() => {
  206. // With default 20% threshold, spools with 10% and 15% remaining should be counted (2 spools)
  207. const lowStockSection = screen.getByText(/low stock/i).closest('div');
  208. expect(lowStockSection).toBeInTheDocument();
  209. });
  210. });
  211. it('does not use localStorage for threshold', async () => {
  212. // Set a value in localStorage that should be ignored
  213. localStorage.setItem('bambuddy-low-stock-threshold', '50');
  214. render(<InventoryPageRouter />);
  215. await waitFor(() => {
  216. // Should show backend value (20%), not localStorage value (50%)
  217. expect(screen.getByText(/< 20%/i)).toBeInTheDocument();
  218. });
  219. });
  220. });
  221. describe('updating threshold via UI', () => {
  222. it('shows edit button for threshold', async () => {
  223. const user = userEvent.setup();
  224. render(<InventoryPageRouter />);
  225. await waitFor(() => {
  226. expect(screen.getByText(/< 20%/i)).toBeInTheDocument();
  227. });
  228. // Find the edit button within the low stock threshold section
  229. const thresholdText = screen.getByText(/< 20%/i);
  230. const editButton = thresholdText.parentElement!.querySelector('button[title]') as HTMLElement;
  231. expect(editButton).toBeInTheDocument();
  232. await user.click(editButton);
  233. // Input field should appear with default threshold value
  234. await waitFor(() => {
  235. const input = screen.getByDisplayValue('20');
  236. expect(input).toBeInTheDocument();
  237. });
  238. });
  239. it('updates threshold and persists to backend', async () => {
  240. const user = userEvent.setup();
  241. let updatedSettings: Partial<typeof mockSettings> | null = null;
  242. server.use(
  243. http.put('/api/v1/settings/', async ({ request }) => {
  244. const body = (await request.json()) as Partial<typeof mockSettings>;
  245. updatedSettings = body;
  246. return HttpResponse.json({ ...mockSettings, ...body });
  247. })
  248. );
  249. render(<InventoryPageRouter />);
  250. await waitFor(() => {
  251. expect(screen.getByText(/< 20%/i)).toBeInTheDocument();
  252. });
  253. // Click edit button within the low stock threshold section
  254. const thresholdText = screen.getByText(/< 20%/i);
  255. const editButton = thresholdText.parentElement!.querySelector('button[title]') as HTMLElement;
  256. await user.click(editButton);
  257. // Enter new value
  258. const input = screen.getByDisplayValue('20');
  259. await user.clear(input);
  260. await user.type(input, '15.5');
  261. // Submit form
  262. const saveButton = screen.getByRole('button', { name: /save/i });
  263. await user.click(saveButton);
  264. // Verify API was called with correct value
  265. await waitFor(() => {
  266. expect(updatedSettings).toEqual({ low_stock_threshold: 15.5 });
  267. });
  268. });
  269. it('validates threshold input range', async () => {
  270. const user = userEvent.setup();
  271. let updatedSettings: Partial<typeof mockSettings> | null = null;
  272. server.use(
  273. http.put('/api/v1/settings/', async ({ request }) => {
  274. const body = (await request.json()) as Partial<typeof mockSettings>;
  275. updatedSettings = body;
  276. return HttpResponse.json({ ...mockSettings, ...body });
  277. })
  278. );
  279. render(<InventoryPageRouter />);
  280. await waitFor(() => {
  281. expect(screen.getByText(/< 20%/i)).toBeInTheDocument();
  282. });
  283. // Click edit button within the low stock threshold section
  284. const thresholdText = screen.getByText(/< 20%/i);
  285. const editButton = thresholdText.parentElement!.querySelector('button[title]') as HTMLElement;
  286. await user.click(editButton);
  287. // Try invalid values
  288. const input = screen.getByDisplayValue('20');
  289. // Too low (0 is below the 0.1 minimum)
  290. await user.clear(input);
  291. await user.type(input, '0');
  292. const saveButton = screen.getByRole('button', { name: /save/i });
  293. await user.click(saveButton);
  294. // Should show error and NOT call the PUT endpoint
  295. await waitFor(() => {
  296. expect(updatedSettings).toBeNull();
  297. });
  298. });
  299. it('allows canceling threshold edit', async () => {
  300. const user = userEvent.setup();
  301. render(<InventoryPageRouter />);
  302. await waitFor(() => {
  303. expect(screen.getByText(/< 20%/i)).toBeInTheDocument();
  304. });
  305. // Click edit button within the low stock threshold section
  306. const thresholdText = screen.getByText(/< 20%/i);
  307. const editButton = thresholdText.parentElement!.querySelector('button[title]') as HTMLElement;
  308. await user.click(editButton);
  309. // Change value
  310. const input = screen.getByDisplayValue('20');
  311. await user.clear(input);
  312. await user.type(input, '30');
  313. // Cancel
  314. const cancelButton = screen.getByRole('button', { name: /cancel/i });
  315. await user.click(cancelButton);
  316. // Should revert to original display
  317. await waitFor(() => {
  318. expect(screen.getByText(/< 20%/i)).toBeInTheDocument();
  319. });
  320. });
  321. });
  322. describe('custom threshold from backend', () => {
  323. it('loads custom threshold value from backend', async () => {
  324. server.use(
  325. http.get('/api/v1/settings/', () => {
  326. return HttpResponse.json({ ...mockSettings, low_stock_threshold: 25.0 });
  327. })
  328. );
  329. render(<InventoryPageRouter />);
  330. await waitFor(() => {
  331. expect(screen.getByText(/< 25%/i)).toBeInTheDocument();
  332. });
  333. });
  334. it('applies custom threshold to low stock filtering', async () => {
  335. // With threshold at 30%, all 3 test spools should be low stock (10%, 15%, and we'd need to check 80%)
  336. server.use(
  337. http.get('/api/v1/settings/', () => {
  338. return HttpResponse.json({ ...mockSettings, low_stock_threshold: 30.0 });
  339. })
  340. );
  341. render(<InventoryPageRouter />);
  342. await waitFor(() => {
  343. expect(screen.getByText(/< 30%/i)).toBeInTheDocument();
  344. });
  345. // The low stock count should reflect the new threshold
  346. // Implementation would show appropriate count based on 30% threshold
  347. });
  348. });
  349. });