InventoryPageLowStock.test.tsx 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437
  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. dark_style: 'classic',
  46. dark_background: 'neutral',
  47. dark_accent: 'green',
  48. light_style: 'classic',
  49. light_background: 'neutral',
  50. light_accent: 'green',
  51. ftp_retry_enabled: true,
  52. ftp_retry_count: 3,
  53. ftp_retry_delay: 2,
  54. ftp_timeout: 30,
  55. mqtt_enabled: false,
  56. mqtt_broker: '',
  57. mqtt_port: 1883,
  58. mqtt_username: '',
  59. mqtt_password: '',
  60. mqtt_topic_prefix: 'bambuddy',
  61. mqtt_use_tls: false,
  62. external_url: '',
  63. ha_enabled: false,
  64. ha_url: '',
  65. ha_token: '',
  66. ha_url_from_env: false,
  67. ha_token_from_env: false,
  68. ha_env_managed: false,
  69. library_disk_warning_gb: 5.0,
  70. camera_view_mode: 'window',
  71. preferred_slicer: 'bambu_studio',
  72. prometheus_enabled: false,
  73. prometheus_token: '',
  74. low_stock_threshold: 20.0,
  75. };
  76. const mockSpools = [
  77. {
  78. id: 1,
  79. material: 'PLA',
  80. subtype: null,
  81. brand: 'Polymaker',
  82. color_name: 'Red',
  83. rgba: 'FF0000FF',
  84. label_weight: 1000,
  85. core_weight: 250,
  86. weight_used: 900, // 10% remaining - low stock
  87. slicer_filament: null,
  88. slicer_filament_name: null,
  89. nozzle_temp_min: null,
  90. nozzle_temp_max: null,
  91. note: null,
  92. added_full: null,
  93. last_used: null,
  94. encode_time: null,
  95. tag_uid: null,
  96. tray_uuid: null,
  97. data_origin: null,
  98. tag_type: null,
  99. archived_at: null,
  100. created_at: '2025-01-01T00:00:00Z',
  101. updated_at: '2025-01-01T00:00:00Z',
  102. k_profiles: [],
  103. cost_per_kg: null,
  104. last_scale_weight: null,
  105. last_weighed_at: null,
  106. },
  107. {
  108. id: 2,
  109. material: 'PETG',
  110. subtype: null,
  111. brand: 'eSun',
  112. color_name: 'Blue',
  113. rgba: '0000FFFF',
  114. label_weight: 1000,
  115. core_weight: 250,
  116. weight_used: 200, // 80% remaining - not low stock
  117. slicer_filament: null,
  118. slicer_filament_name: null,
  119. nozzle_temp_min: null,
  120. nozzle_temp_max: null,
  121. note: null,
  122. added_full: null,
  123. last_used: null,
  124. encode_time: null,
  125. tag_uid: null,
  126. tray_uuid: null,
  127. data_origin: null,
  128. tag_type: null,
  129. archived_at: null,
  130. created_at: '2025-01-02T00:00:00Z',
  131. updated_at: '2025-01-02T00:00:00Z',
  132. k_profiles: [],
  133. cost_per_kg: null,
  134. last_scale_weight: null,
  135. last_weighed_at: null,
  136. },
  137. {
  138. id: 3,
  139. material: 'ABS',
  140. subtype: null,
  141. brand: 'Hatchbox',
  142. color_name: 'Black',
  143. rgba: '000000FF',
  144. label_weight: 1000,
  145. core_weight: 250,
  146. weight_used: 850, // 15% remaining - low stock
  147. slicer_filament: null,
  148. slicer_filament_name: null,
  149. nozzle_temp_min: null,
  150. nozzle_temp_max: null,
  151. note: null,
  152. added_full: null,
  153. last_used: null,
  154. encode_time: null,
  155. tag_uid: null,
  156. tray_uuid: null,
  157. data_origin: null,
  158. tag_type: null,
  159. archived_at: null,
  160. created_at: '2025-01-03T00:00:00Z',
  161. updated_at: '2025-01-03T00:00:00Z',
  162. k_profiles: [],
  163. cost_per_kg: null,
  164. last_scale_weight: null,
  165. last_weighed_at: null,
  166. },
  167. ];
  168. describe('InventoryPage - Low Stock Threshold', () => {
  169. beforeEach(() => {
  170. // Clear localStorage to ensure we're not relying on it
  171. localStorage.clear();
  172. server.use(
  173. http.get('/api/v1/settings/', () => {
  174. return HttpResponse.json(mockSettings);
  175. }),
  176. http.put('/api/v1/settings/', async ({ request }) => {
  177. const body = (await request.json()) as Partial<typeof mockSettings>;
  178. return HttpResponse.json({ ...mockSettings, ...body });
  179. }),
  180. http.get('/api/v1/inventory/spools', () => {
  181. return HttpResponse.json(mockSpools);
  182. }),
  183. http.get('/api/v1/inventory/assignments', () => {
  184. return HttpResponse.json([]);
  185. }),
  186. http.get('/api/v1/spoolman/settings', () => {
  187. return HttpResponse.json({ spoolman_enabled: 'false' });
  188. })
  189. );
  190. });
  191. describe('default threshold from backend', () => {
  192. it('loads the default threshold of 20% from backend settings', async () => {
  193. render(<InventoryPageRouter />);
  194. await waitFor(() => {
  195. // Find the low stock stat showing the threshold
  196. expect(screen.getByText(/< 20%/i)).toBeInTheDocument();
  197. });
  198. });
  199. it('calculates low stock count based on default threshold', async () => {
  200. render(<InventoryPageRouter />);
  201. await waitFor(() => {
  202. // With default 20% threshold, spools with 10% and 15% remaining should be counted (2 spools)
  203. const lowStockSection = screen.getByText(/low stock/i).closest('div');
  204. expect(lowStockSection).toBeInTheDocument();
  205. });
  206. });
  207. it('does not use localStorage for threshold', async () => {
  208. // Set a value in localStorage that should be ignored
  209. localStorage.setItem('bambuddy-low-stock-threshold', '50');
  210. render(<InventoryPageRouter />);
  211. await waitFor(() => {
  212. // Should show backend value (20%), not localStorage value (50%)
  213. expect(screen.getByText(/< 20%/i)).toBeInTheDocument();
  214. });
  215. });
  216. });
  217. describe('updating threshold via UI', () => {
  218. it('shows edit button for threshold', async () => {
  219. const user = userEvent.setup();
  220. render(<InventoryPageRouter />);
  221. await waitFor(() => {
  222. expect(screen.getByText(/< 20%/i)).toBeInTheDocument();
  223. });
  224. // Find the edit button within the low stock threshold section
  225. const thresholdText = screen.getByText(/< 20%/i);
  226. const editButton = thresholdText.parentElement!.querySelector('button[title]') as HTMLElement;
  227. expect(editButton).toBeInTheDocument();
  228. await user.click(editButton);
  229. // Input field should appear with default threshold value
  230. await waitFor(() => {
  231. const input = screen.getByDisplayValue('20');
  232. expect(input).toBeInTheDocument();
  233. });
  234. });
  235. it('updates threshold and persists to backend', async () => {
  236. const user = userEvent.setup();
  237. let updatedSettings: Partial<typeof mockSettings> | null = null;
  238. server.use(
  239. http.put('/api/v1/settings/', async ({ request }) => {
  240. const body = (await request.json()) as Partial<typeof mockSettings>;
  241. updatedSettings = body;
  242. return HttpResponse.json({ ...mockSettings, ...body });
  243. })
  244. );
  245. render(<InventoryPageRouter />);
  246. await waitFor(() => {
  247. expect(screen.getByText(/< 20%/i)).toBeInTheDocument();
  248. });
  249. // Click edit button within the low stock threshold section
  250. const thresholdText = screen.getByText(/< 20%/i);
  251. const editButton = thresholdText.parentElement!.querySelector('button[title]') as HTMLElement;
  252. await user.click(editButton);
  253. // Enter new value
  254. const input = screen.getByDisplayValue('20');
  255. await user.clear(input);
  256. await user.type(input, '15.5');
  257. // Submit form
  258. const saveButton = screen.getByRole('button', { name: /save/i });
  259. await user.click(saveButton);
  260. // Verify API was called with correct value
  261. await waitFor(() => {
  262. expect(updatedSettings).toEqual({ low_stock_threshold: 15.5 });
  263. });
  264. });
  265. it('validates threshold input range', async () => {
  266. const user = userEvent.setup();
  267. let updatedSettings: Partial<typeof mockSettings> | null = null;
  268. server.use(
  269. http.put('/api/v1/settings/', async ({ request }) => {
  270. const body = (await request.json()) as Partial<typeof mockSettings>;
  271. updatedSettings = body;
  272. return HttpResponse.json({ ...mockSettings, ...body });
  273. })
  274. );
  275. render(<InventoryPageRouter />);
  276. await waitFor(() => {
  277. expect(screen.getByText(/< 20%/i)).toBeInTheDocument();
  278. });
  279. // Click edit button within the low stock threshold section
  280. const thresholdText = screen.getByText(/< 20%/i);
  281. const editButton = thresholdText.parentElement!.querySelector('button[title]') as HTMLElement;
  282. await user.click(editButton);
  283. // Try invalid values
  284. const input = screen.getByDisplayValue('20');
  285. // Too low (0 is below the 0.1 minimum)
  286. await user.clear(input);
  287. await user.type(input, '0');
  288. const saveButton = screen.getByRole('button', { name: /save/i });
  289. await user.click(saveButton);
  290. // Should show error and NOT call the PUT endpoint
  291. await waitFor(() => {
  292. expect(updatedSettings).toBeNull();
  293. });
  294. });
  295. it('allows canceling threshold edit', async () => {
  296. const user = userEvent.setup();
  297. render(<InventoryPageRouter />);
  298. await waitFor(() => {
  299. expect(screen.getByText(/< 20%/i)).toBeInTheDocument();
  300. });
  301. // Click edit button within the low stock threshold section
  302. const thresholdText = screen.getByText(/< 20%/i);
  303. const editButton = thresholdText.parentElement!.querySelector('button[title]') as HTMLElement;
  304. await user.click(editButton);
  305. // Change value
  306. const input = screen.getByDisplayValue('20');
  307. await user.clear(input);
  308. await user.type(input, '30');
  309. // Cancel
  310. const cancelButton = screen.getByRole('button', { name: /cancel/i });
  311. await user.click(cancelButton);
  312. // Should revert to original display
  313. await waitFor(() => {
  314. expect(screen.getByText(/< 20%/i)).toBeInTheDocument();
  315. });
  316. });
  317. });
  318. describe('custom threshold from backend', () => {
  319. it('loads custom threshold value from backend', async () => {
  320. server.use(
  321. http.get('/api/v1/settings/', () => {
  322. return HttpResponse.json({ ...mockSettings, low_stock_threshold: 25.0 });
  323. })
  324. );
  325. render(<InventoryPageRouter />);
  326. await waitFor(() => {
  327. expect(screen.getByText(/< 25%/i)).toBeInTheDocument();
  328. });
  329. });
  330. it('applies custom threshold to low stock filtering', async () => {
  331. // With threshold at 30%, all 3 test spools should be low stock (10%, 15%, and we'd need to check 80%)
  332. server.use(
  333. http.get('/api/v1/settings/', () => {
  334. return HttpResponse.json({ ...mockSettings, low_stock_threshold: 30.0 });
  335. })
  336. );
  337. render(<InventoryPageRouter />);
  338. await waitFor(() => {
  339. expect(screen.getByText(/< 30%/i)).toBeInTheDocument();
  340. });
  341. // The low stock count should reflect the new threshold
  342. // Implementation would show appropriate count based on 30% threshold
  343. });
  344. });
  345. describe('per-spool overrides (#729)', () => {
  346. it('per-spool low_stock_threshold_pct overrides the global threshold', async () => {
  347. // Global = 20%. Spool 2 (PETG Blue) is at 80% remaining — normally NOT
  348. // low-stock. But its per-spool threshold is 90% (it's a "production" spool
  349. // the user wants to alert on early), so it MUST count as low-stock.
  350. // Spool 1 (10%) and Spool 3 (15%) are already < 20% and stay low-stock.
  351. // Expected low-stock count: 3 (was 2 before the override).
  352. const spoolsWithOverride = mockSpools.map((s) =>
  353. s.id === 2 ? { ...s, low_stock_threshold_pct: 90 } : s,
  354. );
  355. server.use(
  356. http.get('/api/v1/inventory/spools', () => HttpResponse.json(spoolsWithOverride)),
  357. );
  358. render(<InventoryPageRouter />);
  359. // The low-stock stat-card pill renders the count with "< 20%" suffix.
  360. // Find it and confirm it reflects 3 (override pulled spool 2 in).
  361. await waitFor(() => {
  362. const pills = screen.getAllByText(/< 20%/i);
  363. expect(pills.length).toBeGreaterThan(0);
  364. });
  365. // The exact count cell is rendered separately. Easiest robust assertion:
  366. // there's a "3" in the low-stock card. Looking up by text would be brittle
  367. // (other "3"s on the page), so we assert via the stat card structure.
  368. // The card is keyed by the same "< 20%" pill we already found.
  369. const pill = screen.getAllByText(/< 20%/i)[0];
  370. const card = pill.closest('div')!.parentElement!;
  371. // Card text reads e.g. "Low Stock3< 20%" with the count concatenated.
  372. expect(card.textContent).toMatch(/Low Stock\D*3\D/);
  373. });
  374. it('category filter chip is hidden when no spool has a category', async () => {
  375. // mockSpools all have category=undefined → chip shouldn't render.
  376. render(<InventoryPageRouter />);
  377. await waitFor(() => {
  378. expect(screen.getAllByText(/< 20%/i).length).toBeGreaterThan(0);
  379. });
  380. // None of the spools carry a category, so the inventory.category chip
  381. // never appears.
  382. expect(screen.queryByRole('combobox', { name: /category/i })).not.toBeInTheDocument();
  383. });
  384. });
  385. });