ProfilesPageWiring.test.tsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308
  1. /**
  2. * ProfilesPage wiring (Bambu Cloud tab): preset create / edit / delete write
  3. * to the user's Bambu Cloud account, so pin exactly what each one sends, and
  4. * the cloud sign-in / token / logout requests.
  5. */
  6. import { describe, it, expect, beforeEach, afterEach } from 'vitest';
  7. import { screen, waitFor } from '@testing-library/react';
  8. import userEvent from '@testing-library/user-event';
  9. import { http, HttpResponse } from 'msw';
  10. import { render } from '../utils';
  11. import { server } from '../mocks/server';
  12. import { ProfilesPage } from '../../pages/ProfilesPage';
  13. import { setAuthToken } from '../../api/client';
  14. type Sent = { method: string; path: string; body: unknown };
  15. const USER_PRESET = {
  16. setting_id: 'PFUS-wiring-1',
  17. name: 'My Wiring PLA @BBL X1C',
  18. type: 'filament',
  19. version: null,
  20. user_id: 'u1',
  21. updated_time: null,
  22. is_custom: true,
  23. };
  24. const BUILTIN_PRESET = {
  25. setting_id: 'GFSA00',
  26. name: 'Bambu PLA Basic @BBL X1C',
  27. type: 'filament',
  28. version: null,
  29. user_id: null,
  30. updated_time: null,
  31. is_custom: false,
  32. };
  33. let sent: Sent[];
  34. let authenticated: boolean;
  35. async function record(request: Request) {
  36. let body: unknown = null;
  37. try {
  38. body = await request.json();
  39. } catch {
  40. body = null;
  41. }
  42. sent.push({ method: request.method, path: new URL(request.url).pathname, body });
  43. }
  44. const writes = () => sent.filter((s) => s.method !== 'GET');
  45. beforeEach(() => {
  46. sent = [];
  47. authenticated = true;
  48. server.use(
  49. http.get('*/api/v1/cloud/status', () =>
  50. HttpResponse.json(
  51. authenticated
  52. ? { is_authenticated: true, email: 'user@example.com', region: 'global' }
  53. : { is_authenticated: false, email: null },
  54. ),
  55. ),
  56. http.get('*/api/v1/printers/', () => HttpResponse.json([])),
  57. http.get('*/api/v1/cloud/settings', () =>
  58. HttpResponse.json({ filament: [USER_PRESET, BUILTIN_PRESET], printer: [], process: [] }),
  59. ),
  60. http.get('*/api/v1/cloud/settings/:id', ({ params }) =>
  61. HttpResponse.json({
  62. setting_id: params.id,
  63. name: params.id === USER_PRESET.setting_id ? USER_PRESET.name : BUILTIN_PRESET.name,
  64. base_id: 'GFSA00',
  65. setting: { inherits: BUILTIN_PRESET.name, nozzle_temperature: ['220'] },
  66. }),
  67. ),
  68. http.get('*/api/v1/cloud/fields/:type', () =>
  69. HttpResponse.json({ version: '1', description: '', fields: [] }),
  70. ),
  71. http.post('*/api/v1/cloud/settings', async ({ request }) => {
  72. await record(request);
  73. return HttpResponse.json({ setting_id: 'PFUS-new' });
  74. }),
  75. http.put('*/api/v1/cloud/settings/:id', async ({ request }) => {
  76. await record(request);
  77. return HttpResponse.json({ setting_id: USER_PRESET.setting_id });
  78. }),
  79. http.delete('*/api/v1/cloud/settings/:id', async ({ request }) => {
  80. await record(request);
  81. return HttpResponse.json({ success: true });
  82. }),
  83. http.post('*/api/v1/cloud/login', async ({ request }) => {
  84. await record(request);
  85. return HttpResponse.json({
  86. success: false,
  87. needs_verification: true,
  88. verification_type: 'email',
  89. tfa_key: null,
  90. message: 'Code sent',
  91. });
  92. }),
  93. http.post('*/api/v1/cloud/verify', async ({ request }) => {
  94. await record(request);
  95. return HttpResponse.json({ success: true, needs_verification: false, message: 'ok' });
  96. }),
  97. http.post('*/api/v1/cloud/token', async ({ request }) => {
  98. await record(request);
  99. return HttpResponse.json({ is_authenticated: true, email: 'user@example.com' });
  100. }),
  101. http.post('*/api/v1/cloud/logout', async ({ request }) => {
  102. await record(request);
  103. return HttpResponse.json({ success: true });
  104. }),
  105. );
  106. });
  107. afterEach(() => {
  108. setAuthToken(null);
  109. });
  110. async function openUserPreset(user: ReturnType<typeof userEvent.setup>) {
  111. await user.click(await screen.findByText(USER_PRESET.name));
  112. // The detail modal's Edit is enabled once the detail has loaded.
  113. const edit = await screen.findByRole('button', { name: 'Edit' });
  114. await waitFor(() => expect(edit).not.toBeDisabled());
  115. return edit;
  116. }
  117. describe('ProfilesPage wiring: cloud presets', () => {
  118. it('New Preset -> Create sends createCloudSetting with type, name, base_id and the settings', async () => {
  119. const user = userEvent.setup();
  120. render(<ProfilesPage />);
  121. await user.click(await screen.findByRole('button', { name: 'New Preset' }));
  122. await screen.findByText('Create New Preset');
  123. const baseSelect = screen.getByText('Base Preset').parentElement!.querySelector('select')!;
  124. await user.selectOptions(baseSelect, 'GFSA00');
  125. await user.type(screen.getByText('Preset Name').parentElement!.querySelector('input')!, 'Wiring PLA Fast');
  126. const create = screen.getByRole('button', { name: 'Create' });
  127. await waitFor(() => expect(create).not.toBeDisabled());
  128. await user.click(create);
  129. await waitFor(() => expect(writes()).toHaveLength(1));
  130. expect(writes()[0]).toEqual({
  131. method: 'POST',
  132. path: '/api/v1/cloud/settings',
  133. body: {
  134. type: 'filament',
  135. name: 'Wiring PLA Fast',
  136. base_id: 'GFSA00',
  137. setting: { inherits: BUILTIN_PRESET.name, filament_settings_id: '"Wiring PLA Fast"' },
  138. },
  139. });
  140. });
  141. it('Create stays disabled without a base preset, so nothing is sent', async () => {
  142. const user = userEvent.setup();
  143. render(<ProfilesPage />);
  144. await user.click(await screen.findByRole('button', { name: 'New Preset' }));
  145. await screen.findByText('Create New Preset');
  146. await user.type(screen.getByText('Preset Name').parentElement!.querySelector('input')!, 'No Base');
  147. expect(screen.getByRole('button', { name: 'Create' })).toBeDisabled();
  148. expect(writes()).toEqual([]);
  149. });
  150. it('Edit -> Save sends updateCloudSetting(setting_id, { name, setting })', async () => {
  151. const user = userEvent.setup();
  152. render(<ProfilesPage />);
  153. await user.click(await openUserPreset(user));
  154. const nameInput = await screen.findByDisplayValue(USER_PRESET.name);
  155. await user.clear(nameInput);
  156. await user.type(nameInput, 'Renamed Wiring PLA');
  157. await user.click(screen.getByRole('button', { name: 'Save' }));
  158. await waitFor(() => expect(writes()).toHaveLength(1));
  159. expect(writes()[0]).toEqual({
  160. method: 'PUT',
  161. path: '/api/v1/cloud/settings/PFUS-wiring-1',
  162. body: {
  163. name: 'Renamed Wiring PLA',
  164. setting: { inherits: BUILTIN_PRESET.name, nozzle_temperature: ['220'] },
  165. },
  166. });
  167. });
  168. it('Delete -> confirm sends deleteCloudSetting for that preset', async () => {
  169. const user = userEvent.setup();
  170. render(<ProfilesPage />);
  171. const edit = await openUserPreset(user);
  172. await user.click(edit.nextElementSibling as HTMLElement);
  173. expect(await screen.findByText('Delete this preset?')).toBeInTheDocument();
  174. expect(writes()).toEqual([]);
  175. await user.click(screen.getByRole('button', { name: 'Delete' }));
  176. await waitFor(() => expect(writes()).toHaveLength(1));
  177. expect(writes()[0]).toMatchObject({ method: 'DELETE', path: '/api/v1/cloud/settings/PFUS-wiring-1' });
  178. });
  179. it('Delete -> cancel sends nothing', async () => {
  180. const user = userEvent.setup();
  181. render(<ProfilesPage />);
  182. const edit = await openUserPreset(user);
  183. await user.click(edit.nextElementSibling as HTMLElement);
  184. await screen.findByText('Delete this preset?');
  185. await user.click(screen.getByRole('button', { name: 'Cancel' }));
  186. await waitFor(() => expect(screen.queryByText('Delete this preset?')).not.toBeInTheDocument());
  187. expect(writes()).toEqual([]);
  188. });
  189. it('built-in presets offer no edit or delete', async () => {
  190. const user = userEvent.setup();
  191. render(<ProfilesPage />);
  192. await user.click(await screen.findByText(BUILTIN_PRESET.name));
  193. await screen.findByRole('button', { name: 'Close' });
  194. expect(screen.queryByRole('button', { name: 'Edit' })).not.toBeInTheDocument();
  195. });
  196. });
  197. describe('ProfilesPage wiring: cloud sign-in', () => {
  198. it('login then verify send the credentials, the code and the region', async () => {
  199. authenticated = false;
  200. const user = userEvent.setup();
  201. render(<ProfilesPage />);
  202. await user.type(await screen.findByPlaceholderText('your@email.com'), 'user@example.com');
  203. await user.type(screen.getByPlaceholderText('••••••••'), 'test-password-1');
  204. await user.selectOptions(screen.getByRole('combobox'), 'china');
  205. await user.click(screen.getByRole('button', { name: 'Login' }));
  206. await waitFor(() => expect(writes()).toHaveLength(1));
  207. expect(writes()[0]).toEqual({
  208. method: 'POST',
  209. path: '/api/v1/cloud/login',
  210. body: { email: 'user@example.com', password: 'test-password-1', region: 'china' },
  211. });
  212. await user.type(await screen.findByPlaceholderText('000000'), '123456');
  213. await user.click(screen.getByRole('button', { name: 'Verify' }));
  214. await waitFor(() => expect(writes()).toHaveLength(2));
  215. expect(writes()[1]).toEqual({
  216. method: 'POST',
  217. path: '/api/v1/cloud/verify',
  218. body: { email: 'user@example.com', code: '123456', region: 'china' },
  219. });
  220. });
  221. it('the token route sends cloudSetToken with the token and region', async () => {
  222. authenticated = false;
  223. const user = userEvent.setup();
  224. render(<ProfilesPage />);
  225. await user.click(await screen.findByRole('button', { name: 'Use access token instead' }));
  226. await user.type(await screen.findByPlaceholderText('eyJ...'), 'test-token-1');
  227. await user.click(screen.getByRole('button', { name: 'Set Token' }));
  228. await waitFor(() => expect(writes()).toHaveLength(1));
  229. expect(writes()[0]).toEqual({
  230. method: 'POST',
  231. path: '/api/v1/cloud/token',
  232. body: { access_token: 'test-token-1', region: 'global' },
  233. });
  234. });
  235. it('Logout sends cloudLogout', async () => {
  236. const user = userEvent.setup();
  237. render(<ProfilesPage />);
  238. await user.click(await screen.findByRole('button', { name: 'Logout' }));
  239. await waitFor(() => expect(writes()).toHaveLength(1));
  240. expect(writes()[0]).toMatchObject({ method: 'POST', path: '/api/v1/cloud/logout' });
  241. });
  242. });
  243. describe('ProfilesPage wiring: permissions', () => {
  244. it('without cloud:auth, New Preset, Logout, Edit and Delete are disabled', async () => {
  245. setAuthToken('test-token-1', 'session');
  246. server.use(
  247. http.get('*/api/v1/auth/status', () => HttpResponse.json({ auth_enabled: true, requires_setup: false })),
  248. http.get('*/api/v1/auth/me', () =>
  249. HttpResponse.json({
  250. id: 9,
  251. username: 'viewer',
  252. is_active: true,
  253. is_admin: false,
  254. groups: [],
  255. permissions: ['cloud:read'],
  256. created_at: '2026-01-01T00:00:00Z',
  257. }),
  258. ),
  259. );
  260. const user = userEvent.setup();
  261. render(<ProfilesPage />);
  262. await waitFor(() => expect(screen.getByRole('button', { name: 'New Preset' })).toBeDisabled());
  263. expect(screen.getByRole('button', { name: 'Logout' })).toBeDisabled();
  264. await user.click(screen.getByText(USER_PRESET.name));
  265. const edit = await screen.findByRole('button', { name: 'Edit' });
  266. expect(edit).toBeDisabled();
  267. expect(edit.nextElementSibling).toBeDisabled();
  268. expect(writes()).toEqual([]);
  269. });
  270. });