AddSmartPlugModalWiring.test.tsx 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286
  1. /**
  2. * AddSmartPlugModal wiring: the create / update payloads per plug type,
  3. * the printer link, and the Tasmota discovery scan requests.
  4. */
  5. import { describe, it, expect, beforeEach } from 'vitest';
  6. import { screen, waitFor } from '@testing-library/react';
  7. import userEvent from '@testing-library/user-event';
  8. import { http, HttpResponse } from 'msw';
  9. import { render } from '../utils';
  10. import { server } from '../mocks/server';
  11. import { AddSmartPlugModal } from '../../components/AddSmartPlugModal';
  12. import type { SmartPlug } from '../../api/client';
  13. type Sent = { method: string; path: string; body: Record<string, unknown> | null };
  14. const PRINTERS = [
  15. { id: 3, name: 'Wiring Printer A', model: 'X1C', serial_number: 'SN-TEST-A' },
  16. { id: 4, name: 'Wiring Printer B', model: 'P1S', serial_number: 'SN-TEST-B' },
  17. ];
  18. let sent: Sent[];
  19. async function record(request: Request) {
  20. let body: Record<string, unknown> | null = null;
  21. try {
  22. body = (await request.json()) as Record<string, unknown>;
  23. } catch {
  24. body = null;
  25. }
  26. sent.push({ method: request.method, path: new URL(request.url).pathname, body });
  27. }
  28. const writes = () => sent.filter((s) => s.method !== 'GET');
  29. beforeEach(() => {
  30. sent = [];
  31. server.use(
  32. http.get('*/api/v1/printers/', () => HttpResponse.json(PRINTERS)),
  33. http.get('*/api/v1/smart-plugs/', () => HttpResponse.json([])),
  34. http.get('*/api/v1/settings/', () =>
  35. HttpResponse.json({
  36. ha_enabled: true,
  37. ha_url: 'http://ha.example',
  38. ha_token: 'test-token-1',
  39. mqtt_broker: 'mqtt.example',
  40. }),
  41. ),
  42. http.get('*/api/v1/smart-plugs/ha/entities', () =>
  43. HttpResponse.json([
  44. { entity_id: 'switch.wiring_plug', friendly_name: 'Wiring Switch', state: 'off', domain: 'switch' },
  45. ]),
  46. ),
  47. http.get('*/api/v1/smart-plugs/ha/sensors', () => HttpResponse.json([])),
  48. http.post('*/api/v1/smart-plugs/', async ({ request }) => {
  49. await record(request);
  50. return HttpResponse.json({ id: 99 });
  51. }),
  52. http.patch('*/api/v1/smart-plugs/:id', async ({ request }) => {
  53. await record(request);
  54. return HttpResponse.json({ id: 5 });
  55. }),
  56. );
  57. });
  58. function nameInput() {
  59. return screen.getByPlaceholderText('Living Room Plug');
  60. }
  61. describe('AddSmartPlugModal wiring: create', () => {
  62. it('Tasmota: sends ip, type, printer_id and the default automation flags, no other type fields', async () => {
  63. const user = userEvent.setup();
  64. render(<AddSmartPlugModal onClose={() => undefined} />);
  65. await user.type(screen.getByPlaceholderText('192.168.1.100'), '192.0.2.20');
  66. await user.type(nameInput(), 'Desk Plug');
  67. const printerSelect = await screen.findByDisplayValue('No printer (manual control only)');
  68. await screen.findByRole('option', { name: 'Wiring Printer B' });
  69. await user.selectOptions(printerSelect, '4');
  70. await user.click(screen.getByRole('button', { name: 'Add' }));
  71. await waitFor(() => expect(writes()).toHaveLength(1));
  72. const req = writes()[0];
  73. expect(req.method).toBe('POST');
  74. expect(req.path).toBe('/api/v1/smart-plugs/');
  75. expect(req.body).toMatchObject({
  76. name: 'Desk Plug',
  77. plug_type: 'tasmota',
  78. ip_address: '192.0.2.20',
  79. printer_id: 4,
  80. controls_printer_power: true,
  81. ha_entity_id: null,
  82. mqtt_power_topic: null,
  83. rest_on_url: null,
  84. username: null,
  85. password: null,
  86. });
  87. });
  88. it('Tasmota: unlinked sends printer_id null', async () => {
  89. const user = userEvent.setup();
  90. render(<AddSmartPlugModal onClose={() => undefined} />);
  91. await user.type(screen.getByPlaceholderText('192.168.1.100'), '192.0.2.21');
  92. await user.type(nameInput(), 'Loose Plug');
  93. await user.click(screen.getByRole('button', { name: 'Add' }));
  94. await waitFor(() => expect(writes()).toHaveLength(1));
  95. expect(writes()[0].body).toMatchObject({ plug_type: 'tasmota', printer_id: null });
  96. });
  97. it('Tasmota without an IP sends nothing', async () => {
  98. const user = userEvent.setup();
  99. render(<AddSmartPlugModal onClose={() => undefined} />);
  100. await user.type(nameInput(), 'No IP');
  101. await user.click(screen.getByRole('button', { name: 'Add' }));
  102. expect(await screen.findByText(/IP address is required/i)).toBeInTheDocument();
  103. expect(writes()).toEqual([]);
  104. });
  105. it('Home Assistant: sends the picked entity and no Tasmota/MQTT/REST fields', async () => {
  106. const user = userEvent.setup();
  107. render(<AddSmartPlugModal onClose={() => undefined} />);
  108. await user.click(screen.getByRole('button', { name: 'HA' }));
  109. const search = await screen.findByPlaceholderText(/search/i);
  110. await user.click(search);
  111. await user.click(await screen.findByText('Wiring Switch'));
  112. await user.click(screen.getByRole('button', { name: 'Add' }));
  113. await waitFor(() => expect(writes()).toHaveLength(1));
  114. expect(writes()[0].body).toMatchObject({
  115. name: 'Wiring Switch',
  116. plug_type: 'homeassistant',
  117. ha_entity_id: 'switch.wiring_plug',
  118. ip_address: null,
  119. username: null,
  120. password: null,
  121. mqtt_power_topic: null,
  122. rest_on_url: null,
  123. printer_id: null,
  124. });
  125. });
  126. it('switching from HA to Tasmota drops the picked HA entity from the payload', async () => {
  127. const user = userEvent.setup();
  128. render(<AddSmartPlugModal onClose={() => undefined} />);
  129. await user.click(screen.getByRole('button', { name: 'HA' }));
  130. await user.click(await screen.findByPlaceholderText(/search/i));
  131. await user.click(await screen.findByText('Wiring Switch'));
  132. await user.click(screen.getByRole('button', { name: 'Tasmota' }));
  133. await user.type(screen.getByPlaceholderText('192.168.1.100'), '192.0.2.22');
  134. await user.click(screen.getByRole('button', { name: 'Add' }));
  135. await waitFor(() => expect(writes()).toHaveLength(1));
  136. expect(writes()[0].body).toMatchObject({
  137. plug_type: 'tasmota',
  138. ip_address: '192.0.2.22',
  139. ha_entity_id: null,
  140. ha_power_entity: null,
  141. });
  142. });
  143. it('MQTT: sends only the MQTT topic fields', async () => {
  144. const user = userEvent.setup();
  145. render(<AddSmartPlugModal onClose={() => undefined} />);
  146. await user.click(screen.getByRole('button', { name: 'MQTT' }));
  147. await user.type(await screen.findByPlaceholderText('zigbee2mqtt/shelly-working-room'), 'tele/wiring/SENSOR');
  148. await user.type(nameInput(), 'Meter');
  149. await user.click(screen.getByRole('button', { name: 'Add' }));
  150. await waitFor(() => expect(writes()).toHaveLength(1));
  151. expect(writes()[0].body).toMatchObject({
  152. plug_type: 'mqtt',
  153. mqtt_power_topic: 'tele/wiring/SENSOR',
  154. mqtt_energy_topic: null,
  155. ip_address: null,
  156. ha_entity_id: null,
  157. rest_on_url: null,
  158. });
  159. });
  160. it('REST: sends the REST URLs and method only', async () => {
  161. const user = userEvent.setup();
  162. render(<AddSmartPlugModal onClose={() => undefined} />);
  163. await user.click(screen.getByRole('button', { name: 'REST' }));
  164. const [onUrl, offUrl] = await screen.findAllByPlaceholderText('http://openhab:8080/rest/items/MyPlug');
  165. await user.type(onUrl, 'http://plug.example/on');
  166. await user.type(offUrl, 'http://plug.example/off');
  167. await user.type(nameInput(), 'Rest Plug');
  168. await user.click(screen.getByRole('button', { name: 'Add' }));
  169. await waitFor(() => expect(writes()).toHaveLength(1));
  170. expect(writes()[0].body).toMatchObject({
  171. plug_type: 'rest',
  172. rest_on_url: 'http://plug.example/on',
  173. rest_off_url: 'http://plug.example/off',
  174. rest_method: 'POST',
  175. ip_address: null,
  176. ha_entity_id: null,
  177. mqtt_power_topic: null,
  178. username: null,
  179. password: null,
  180. });
  181. });
  182. });
  183. describe('AddSmartPlugModal wiring: edit', () => {
  184. it('PATCHes the edited plug by id, keeping the saved-secret placeholder and the changed name', async () => {
  185. const user = userEvent.setup();
  186. const existing = {
  187. id: 5,
  188. name: 'Old Name',
  189. plug_type: 'tasmota',
  190. ip_address: '192.0.2.30',
  191. username: 'admin',
  192. password: '********',
  193. enabled: true,
  194. printer_id: 3,
  195. controls_printer_power: false,
  196. } as unknown as SmartPlug;
  197. render(<AddSmartPlugModal plug={existing} onClose={() => undefined} />);
  198. const name = await screen.findByDisplayValue('Old Name');
  199. await user.clear(name);
  200. await user.type(name, 'New Name');
  201. await user.click(screen.getByRole('button', { name: 'Save' }));
  202. await waitFor(() => expect(writes()).toHaveLength(1));
  203. const req = writes()[0];
  204. expect(req.method).toBe('PATCH');
  205. expect(req.path).toBe('/api/v1/smart-plugs/5');
  206. expect(req.body).toMatchObject({
  207. name: 'New Name',
  208. ip_address: '192.0.2.30',
  209. username: 'admin',
  210. // The write-only placeholder goes back untouched; the backend keeps the stored secret.
  211. password: '********',
  212. printer_id: 3,
  213. controls_printer_power: false,
  214. });
  215. });
  216. });
  217. describe('AddSmartPlugModal wiring: Tasmota discovery', () => {
  218. it('starts and stops the scan, and picking a found device fills IP and name', async () => {
  219. server.use(
  220. http.post('*/api/v1/smart-plugs/discover/scan', async ({ request }) => {
  221. await record(request);
  222. return HttpResponse.json({ running: true, scanned: 0, total: 254 });
  223. }),
  224. http.get('*/api/v1/smart-plugs/discover/status', () =>
  225. HttpResponse.json({ running: true, scanned: 10, total: 254 }),
  226. ),
  227. http.get('*/api/v1/smart-plugs/discover/devices', () =>
  228. HttpResponse.json([{ ip_address: '192.0.2.55', name: 'Found Plug', state: 'ON' }]),
  229. ),
  230. http.post('*/api/v1/smart-plugs/discover/stop', async ({ request }) => {
  231. await record(request);
  232. return HttpResponse.json({ running: false, scanned: 10, total: 254 });
  233. }),
  234. );
  235. const user = userEvent.setup();
  236. render(<AddSmartPlugModal onClose={() => undefined} />);
  237. await user.click(screen.getByRole('button', { name: 'Discover Tasmota Devices' }));
  238. await waitFor(() => expect(writes().map((w) => w.path)).toEqual(['/api/v1/smart-plugs/discover/scan']));
  239. await user.click(await screen.findByText('Found Plug'));
  240. expect(screen.getByPlaceholderText('192.168.1.100')).toHaveValue('192.0.2.55');
  241. expect(nameInput()).toHaveValue('Found Plug');
  242. await user.click(screen.getByRole('button', { name: 'Stop Scanning' }));
  243. await waitFor(() =>
  244. expect(writes().map((w) => w.path)).toEqual([
  245. '/api/v1/smart-plugs/discover/scan',
  246. '/api/v1/smart-plugs/discover/stop',
  247. ]),
  248. );
  249. });
  250. });