GitHubRestoreModal.test.tsx 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696
  1. /**
  2. * Tests for the Restore from Git Backup modal (#2656).
  3. */
  4. import { describe, it, expect, vi, beforeEach } from 'vitest';
  5. import { screen, waitFor } from '@testing-library/react';
  6. import userEvent from '@testing-library/user-event';
  7. import { delay, http, HttpResponse } from 'msw';
  8. import { QueryClient } from '@tanstack/react-query';
  9. import { render } from '../utils';
  10. import { server } from '../mocks/server';
  11. import { GitHubRestoreModal } from '../../components/GitHubRestoreModal';
  12. const mockCommits = {
  13. success: true,
  14. message: 'OK',
  15. branch: 'main',
  16. commits: [
  17. {
  18. sha: 'aaa1111bbb2222ccc3333ddd4444eee5555ffff0',
  19. message: 'Bambuddy backup - 2026-07-02 10:00:00 UTC',
  20. author: 'Bambuddy',
  21. date: '2026-07-02T10:00:00Z',
  22. },
  23. {
  24. sha: 'bbb2222ccc3333ddd4444eee5555ffff0aaa1111',
  25. message: 'Bambuddy backup - 2026-07-01 10:00:00 UTC',
  26. author: 'Bambuddy',
  27. date: '2026-07-01T10:00:00Z',
  28. },
  29. ],
  30. };
  31. const mockPreview = {
  32. success: true,
  33. message: 'OK',
  34. ref: 'aaa1111bbb2222ccc3333ddd4444eee5555ffff0',
  35. commit: mockCommits.commits[0],
  36. metadata_version: '1.0',
  37. // The server describes each caveat as a code plus typed params, carrying the
  38. // English rendering as `detail` for i18next's defaultValue (#2656). Note the
  39. // fixture's English deliberately differs from en.ts, so an assertion on the
  40. // locale string proves the code was translated rather than echoed.
  41. categories: [
  42. {
  43. category: 'archives',
  44. available: true,
  45. item_count: 30,
  46. detail: 'raw server English, should not be rendered',
  47. detail_code: 'archivesMetadataOnly',
  48. detail_params: {},
  49. },
  50. { category: 'spools', available: true, item_count: 4, detail: null, detail_code: null, detail_params: {} },
  51. { category: 'settings', available: true, item_count: 12, detail: null, detail_code: null, detail_params: {} },
  52. {
  53. category: 'kprofiles',
  54. available: false,
  55. item_count: 0,
  56. detail: 'raw server English, should not be rendered',
  57. detail_code: 'notPresent',
  58. detail_params: {},
  59. },
  60. ],
  61. };
  62. // The default fixture has no K-profiles in the commit, which is the one category
  63. // whose row cannot be selected there.
  64. const mockPreviewWithKprofiles = {
  65. ...mockPreview,
  66. categories: mockPreview.categories.map((c) =>
  67. c.category === 'kprofiles'
  68. ? { category: 'kprofiles', available: true, item_count: 3, detail: null, detail_code: null, detail_params: {} }
  69. : c
  70. ),
  71. };
  72. type JsonBody = Record<string, unknown>;
  73. function mockEndpoints(overrides: { preview?: JsonBody; commits?: JsonBody } = {}) {
  74. server.use(
  75. http.get('/api/v1/github-backup/commits', () =>
  76. HttpResponse.json(overrides.commits ?? (mockCommits as unknown as JsonBody))
  77. ),
  78. http.get('/api/v1/github-backup/restore/preview', () =>
  79. HttpResponse.json(overrides.preview ?? (mockPreview as unknown as JsonBody))
  80. ),
  81. );
  82. }
  83. describe('GitHubRestoreModal', () => {
  84. beforeEach(() => {
  85. vi.clearAllMocks();
  86. mockEndpoints();
  87. });
  88. it('renders the title and commit picker', async () => {
  89. render(<GitHubRestoreModal onClose={vi.fn()} />);
  90. await waitFor(() => {
  91. expect(screen.getByText('Restore from Git Backup')).toBeInTheDocument();
  92. });
  93. expect(screen.getByLabelText('Backup commit')).toBeInTheDocument();
  94. });
  95. it('defaults to the latest commit and lists recent commits', async () => {
  96. render(<GitHubRestoreModal onClose={vi.fn()} />);
  97. const select = (await screen.findByLabelText('Backup commit')) as HTMLSelectElement;
  98. expect(select.value).toBe('HEAD');
  99. await waitFor(() => {
  100. expect(screen.getByText(/Latest backup/)).toBeInTheDocument();
  101. });
  102. // Commits are labelled by short SHA.
  103. await waitFor(() => {
  104. expect(screen.getByRole('option', { name: /aaa1111/ })).toBeInTheDocument();
  105. expect(screen.getByRole('option', { name: /bbb2222/ })).toBeInTheDocument();
  106. });
  107. });
  108. it('shows item counts for categories present in the commit', async () => {
  109. render(<GitHubRestoreModal onClose={vi.fn()} />);
  110. await waitFor(() => {
  111. expect(screen.getByText('30 in backup')).toBeInTheDocument();
  112. });
  113. expect(screen.getByText('4 in backup')).toBeInTheDocument();
  114. expect(screen.getByText('12 in backup')).toBeInTheDocument();
  115. });
  116. it('translates preview caveats rather than echoing the server English', async () => {
  117. render(<GitHubRestoreModal onClose={vi.fn()} />);
  118. await waitFor(() => {
  119. expect(
  120. screen.getByText('Metadata only - 3MF files and thumbnails are not in a Git backup')
  121. ).toBeInTheDocument();
  122. });
  123. expect(screen.queryAllByText('raw server English, should not be rendered')).toHaveLength(0);
  124. });
  125. it('falls back to the server English for a code it does not know', async () => {
  126. // A newer backend adding a detail_code this build has no key for must not
  127. // print the raw key at the user. Same defaultValue arm backup.pathCheck uses.
  128. mockEndpoints({
  129. preview: {
  130. ...mockPreview,
  131. categories: [
  132. {
  133. category: 'spools',
  134. available: true,
  135. item_count: 4,
  136. detail: 'Something a future release explains',
  137. detail_code: 'somethingThisBuildHasNeverHeardOf',
  138. detail_params: {},
  139. },
  140. ],
  141. },
  142. });
  143. render(<GitHubRestoreModal onClose={vi.fn()} />);
  144. await waitFor(() => {
  145. expect(screen.getByText('Something a future release explains')).toBeInTheDocument();
  146. });
  147. });
  148. it('disables a category that is absent from the commit', async () => {
  149. render(<GitHubRestoreModal onClose={vi.fn()} />);
  150. await waitFor(() => {
  151. expect(screen.getByText('Not present in this backup commit')).toBeInTheDocument();
  152. });
  153. const checkboxes = screen.getAllByRole('checkbox') as HTMLInputElement[];
  154. // Four categories in fixed order: archives, spools, settings, kprofiles.
  155. expect(checkboxes).toHaveLength(4);
  156. expect(checkboxes[3].disabled).toBe(true);
  157. expect(checkboxes[0].disabled).toBe(false);
  158. });
  159. it('keeps Restore disabled until a category is selected', async () => {
  160. render(<GitHubRestoreModal onClose={vi.fn()} />);
  161. const restoreButton = await screen.findByRole('button', { name: /Restore$/ });
  162. expect(restoreButton).toBeDisabled();
  163. // Wait for the preview to populate the category list before selecting.
  164. const checkboxes = await waitFor(() => {
  165. const found = screen.getAllByRole('checkbox') as HTMLInputElement[];
  166. expect(found).toHaveLength(4);
  167. return found;
  168. });
  169. await userEvent.click(checkboxes[1]);
  170. await waitFor(() => expect(restoreButton).not.toBeDisabled());
  171. expect(screen.getByText('1 selected')).toBeInTheDocument();
  172. });
  173. it('requires confirmation before sending the restore', async () => {
  174. let restoreCalls = 0;
  175. server.use(
  176. http.post('/api/v1/github-backup/restore', async () => {
  177. restoreCalls += 1;
  178. return HttpResponse.json({
  179. success: true,
  180. message: 'Restored 4 item(s) from aaa1111',
  181. log_id: 3,
  182. ref: mockPreview.ref,
  183. results: { spools: { restored: 4, skipped: 1, failed: 0, notes: [] } },
  184. });
  185. })
  186. );
  187. render(<GitHubRestoreModal onClose={vi.fn()} />);
  188. const checkboxes = await waitFor(() => screen.getAllByRole('checkbox') as HTMLInputElement[]);
  189. await userEvent.click(checkboxes[1]);
  190. await userEvent.click(screen.getByRole('button', { name: /Restore$/ }));
  191. // Confirm dialog appears; nothing sent yet.
  192. await waitFor(() => {
  193. expect(screen.getByText('Restore from backup?')).toBeInTheDocument();
  194. });
  195. expect(restoreCalls).toBe(0);
  196. });
  197. it('sends the selected categories and shows per-category results', async () => {
  198. let body: Record<string, unknown> | null = null;
  199. server.use(
  200. http.post('/api/v1/github-backup/restore', async ({ request }) => {
  201. body = (await request.json()) as Record<string, unknown>;
  202. return HttpResponse.json({
  203. success: true,
  204. message: 'Restored 4 item(s) from aaa1111',
  205. log_id: 3,
  206. ref: mockPreview.ref,
  207. results: {
  208. spools: {
  209. restored: 4,
  210. skipped: 1,
  211. failed: 0,
  212. notes: [
  213. {
  214. code: 'spoolUsageUnresolved',
  215. params: { count: 1 },
  216. message: 'raw server English, should not be rendered',
  217. },
  218. ],
  219. },
  220. },
  221. });
  222. })
  223. );
  224. render(<GitHubRestoreModal onClose={vi.fn()} />);
  225. const checkboxes = await waitFor(() => screen.getAllByRole('checkbox') as HTMLInputElement[]);
  226. await userEvent.click(checkboxes[1]);
  227. await userEvent.click(screen.getByRole('button', { name: /Restore$/ }));
  228. await waitFor(() => screen.getByText('Restore from backup?'));
  229. const confirmButtons = screen.getAllByRole('button', { name: /Restore$/ });
  230. await userEvent.click(confirmButtons[confirmButtons.length - 1]);
  231. await waitFor(() => {
  232. expect(screen.getByText('Restored 4 item(s) from aaa1111')).toBeInTheDocument();
  233. });
  234. // The commit posted is the sha the preview resolved to, not the symbolic
  235. // 'HEAD' the picker defaults to: re-resolving server-side would restore a
  236. // backup that landed after the preview the user actually approved.
  237. expect(body).toMatchObject({
  238. categories: ['spools'],
  239. overwrite_existing: false,
  240. ref: mockPreview.ref,
  241. });
  242. expect(screen.getByText('4 restored, 1 skipped, 0 failed')).toBeInTheDocument();
  243. // The locale string with {{count}} filled in, not the server's English —
  244. // which is what makes the note translatable for a non-English user.
  245. expect(
  246. screen.getByText(/^1 usage record\(s\) skipped - their spool is not in this backup's spool list/)
  247. ).toBeInTheDocument();
  248. expect(screen.queryByText('raw server English, should not be rendered')).not.toBeInTheDocument();
  249. });
  250. it('drops the selection while a newly-picked commit is still being inspected', async () => {
  251. // Switching commits keeps `selected` (it is only pruned once the new preview
  252. // lands), so the footer must not keep counting it: the categories belong to
  253. // the commit that was switched away from, and the user has not seen an item
  254. // count for the new one.
  255. let previewCalls = 0;
  256. server.use(
  257. http.get('/api/v1/github-backup/restore/preview', async () => {
  258. previewCalls += 1;
  259. // The second commit's preview never resolves, holding the modal in the
  260. // in-flight state the assertions below describe.
  261. if (previewCalls > 1) await delay('infinite');
  262. return HttpResponse.json(mockPreview as unknown as JsonBody);
  263. })
  264. );
  265. render(<GitHubRestoreModal onClose={vi.fn()} />);
  266. const checkboxes = await waitFor(() => screen.getAllByRole('checkbox') as HTMLInputElement[]);
  267. await userEvent.click(checkboxes[1]);
  268. await waitFor(() => expect(screen.getByText('1 selected')).toBeInTheDocument());
  269. await userEvent.selectOptions(screen.getByLabelText('Backup commit'), mockCommits.commits[1].sha);
  270. await waitFor(() => expect(screen.getByText('Reading backup contents...')).toBeInTheDocument());
  271. expect(screen.getByText('0 selected')).toBeInTheDocument();
  272. expect(screen.getByRole('button', { name: /Restore$/ })).toBeDisabled();
  273. });
  274. it('sends overwrite_existing when the toggle is on', async () => {
  275. let body: Record<string, unknown> | null = null;
  276. server.use(
  277. http.post('/api/v1/github-backup/restore', async ({ request }) => {
  278. body = (await request.json()) as Record<string, unknown>;
  279. return HttpResponse.json({ success: true, message: 'done', log_id: 1, ref: 'x', results: {} });
  280. })
  281. );
  282. render(<GitHubRestoreModal onClose={vi.fn()} />);
  283. const checkboxes = await waitFor(() => screen.getAllByRole('checkbox') as HTMLInputElement[]);
  284. await userEvent.click(checkboxes[1]);
  285. await userEvent.click(screen.getByRole('switch'));
  286. await userEvent.click(screen.getByRole('button', { name: /Restore$/ }));
  287. await waitFor(() => screen.getByText('Restore from backup?'));
  288. const confirmButtons = screen.getAllByRole('button', { name: /Restore$/ });
  289. await userEvent.click(confirmButtons[confirmButtons.length - 1]);
  290. await waitFor(() => expect(body).toMatchObject({ overwrite_existing: true }));
  291. });
  292. it('warns more strongly when overwrite is enabled', async () => {
  293. render(<GitHubRestoreModal onClose={vi.fn()} />);
  294. const checkboxes = await waitFor(() => screen.getAllByRole('checkbox') as HTMLInputElement[]);
  295. await userEvent.click(checkboxes[1]);
  296. await userEvent.click(screen.getByRole('switch'));
  297. await userEvent.click(screen.getByRole('button', { name: /Restore$/ }));
  298. await waitFor(() => {
  299. expect(screen.getByText(/This cannot be undone/)).toBeInTheDocument();
  300. });
  301. });
  302. // Overwrite-off says existing entries stay as they are. K-profiles are the one
  303. // category that cannot honour that — writing a slot always replaces the
  304. // calibration on the printer — and the backend's note saying so only arrives
  305. // in the result panel, after the MQTT send. So the disclosure has to be on the
  306. // screen where the promise is made, before the user commits to it.
  307. describe('the K-profile exception to overwrite-off', () => {
  308. beforeEach(() => {
  309. mockEndpoints({ preview: mockPreviewWithKprofiles as unknown as JsonBody });
  310. });
  311. it('appears beside the category as soon as it is selected', async () => {
  312. render(<GitHubRestoreModal onClose={vi.fn()} />);
  313. const checkboxes = await waitFor(() => screen.getAllByRole('checkbox') as HTMLInputElement[]);
  314. await userEvent.click(checkboxes[3]);
  315. await waitFor(() => {
  316. expect(screen.getByText(/K-profiles are the exception/)).toBeInTheDocument();
  317. });
  318. // And it goes once overwrite is on, where nothing is promising otherwise.
  319. await userEvent.click(screen.getByRole('switch'));
  320. expect(screen.queryByText(/K-profiles are the exception/)).not.toBeInTheDocument();
  321. });
  322. it('is part of the confirmation the user actually clicks through', async () => {
  323. render(<GitHubRestoreModal onClose={vi.fn()} />);
  324. const checkboxes = await waitFor(() => screen.getAllByRole('checkbox') as HTMLInputElement[]);
  325. await userEvent.click(checkboxes[3]);
  326. await userEvent.click(screen.getByRole('button', { name: /Restore$/ }));
  327. await waitFor(() => screen.getByText('Restore from backup?'));
  328. expect(
  329. screen.getByText(/existing entries stay as they are\. K-profiles are the exception/)
  330. ).toBeInTheDocument();
  331. });
  332. it('stays out of the confirmation for the categories that do keep the promise', async () => {
  333. render(<GitHubRestoreModal onClose={vi.fn()} />);
  334. const checkboxes = await waitFor(() => screen.getAllByRole('checkbox') as HTMLInputElement[]);
  335. await userEvent.click(checkboxes[1]);
  336. await userEvent.click(screen.getByRole('button', { name: /Restore$/ }));
  337. await waitFor(() => screen.getByText('Restore from backup?'));
  338. expect(screen.getByText(/existing entries stay as they are\.$/)).toBeInTheDocument();
  339. expect(screen.queryByText(/K-profiles are the exception/)).not.toBeInTheDocument();
  340. });
  341. it('is redundant with overwrite on, so it is not shown there', async () => {
  342. render(<GitHubRestoreModal onClose={vi.fn()} />);
  343. const checkboxes = await waitFor(() => screen.getAllByRole('checkbox') as HTMLInputElement[]);
  344. await userEvent.click(checkboxes[3]);
  345. await userEvent.click(screen.getByRole('switch'));
  346. await userEvent.click(screen.getByRole('button', { name: /Restore$/ }));
  347. await waitFor(() => screen.getByText(/This cannot be undone/));
  348. expect(screen.queryByText(/K-profiles are the exception/)).not.toBeInTheDocument();
  349. });
  350. });
  351. it('surfaces a preview failure instead of an empty category list', async () => {
  352. mockEndpoints({
  353. preview: {
  354. success: false,
  355. message: 'Commit or tree deadbee not found in the repository',
  356. ref: 'deadbee',
  357. categories: [],
  358. },
  359. });
  360. render(<GitHubRestoreModal onClose={vi.fn()} />);
  361. await waitFor(() => {
  362. expect(screen.getByText('Commit or tree deadbee not found in the repository')).toBeInTheDocument();
  363. });
  364. expect(screen.queryAllByRole('checkbox')).toHaveLength(0);
  365. });
  366. it('surfaces a commit listing failure', async () => {
  367. mockEndpoints({
  368. commits: { success: false, message: 'Invalid access token', branch: 'main', commits: [] },
  369. });
  370. render(<GitHubRestoreModal onClose={vi.fn()} />);
  371. await waitFor(() => {
  372. expect(screen.getByText('Invalid access token')).toBeInTheDocument();
  373. });
  374. });
  375. // A refused restore answers 200 with `success: false` and an empty `results`,
  376. // and two of the five refusals are ordinary conditions rather than errors — a
  377. // restore already running, and a backup mid-flight. Rendering the result panel
  378. // for those put a green tick and "reload so the restored data appears" above a
  379. // message saying nothing had been restored, i.e. a failure that read as a
  380. // success. Empty `results` is the load-bearing half: a failure that did write
  381. // carries its committed categories and does get the panel — see the partial
  382. // test below.
  383. it('reports a backend refusal such as the backup/restore mutex', async () => {
  384. server.use(
  385. http.post('/api/v1/github-backup/restore', () =>
  386. HttpResponse.json({
  387. success: false,
  388. message: 'A backup is currently running. Wait for it to finish before restoring.',
  389. results: {},
  390. })
  391. )
  392. );
  393. render(<GitHubRestoreModal onClose={vi.fn()} />);
  394. const checkboxes = await waitFor(() => screen.getAllByRole('checkbox') as HTMLInputElement[]);
  395. await userEvent.click(checkboxes[1]);
  396. await userEvent.click(screen.getByRole('button', { name: /Restore$/ }));
  397. await waitFor(() => screen.getByText('Restore from backup?'));
  398. const confirmButtons = screen.getAllByRole('button', { name: /Restore$/ });
  399. await userEvent.click(confirmButtons[confirmButtons.length - 1]);
  400. await waitFor(() => {
  401. expect(
  402. screen.getByText('A backup is currently running. Wait for it to finish before restoring.')
  403. ).toBeInTheDocument();
  404. });
  405. // Not the success panel: no reload hint, no "Reload now", and the form is
  406. // still there so the user can retry once the backup finishes.
  407. expect(screen.queryByText(/Reload Bambuddy so the restored data appears/)).not.toBeInTheDocument();
  408. expect(screen.queryByRole('button', { name: /Reload now/ })).not.toBeInTheDocument();
  409. expect(screen.getByLabelText('Backup commit')).toBeInTheDocument();
  410. expect(screen.getByRole('button', { name: /Restore$/ })).toBeEnabled();
  411. });
  412. it('does not refresh the data caches when a restore was refused', async () => {
  413. server.use(
  414. http.post('/api/v1/github-backup/restore', () =>
  415. HttpResponse.json({ success: false, message: 'A restore is already running', results: {} })
  416. )
  417. );
  418. const invalidate = vi.spyOn(QueryClient.prototype, 'invalidateQueries');
  419. render(<GitHubRestoreModal onClose={vi.fn()} />);
  420. const checkboxes = await waitFor(() => screen.getAllByRole('checkbox') as HTMLInputElement[]);
  421. await userEvent.click(checkboxes[1]);
  422. await userEvent.click(screen.getByRole('button', { name: /Restore$/ }));
  423. await waitFor(() => screen.getByText('Restore from backup?'));
  424. const confirmButtons = screen.getAllByRole('button', { name: /Restore$/ });
  425. await userEvent.click(confirmButtons[confirmButtons.length - 1]);
  426. await waitFor(() => screen.getByText('A restore is already running'));
  427. const keys = invalidate.mock.calls.map((c) => JSON.stringify(c[0]?.queryKey));
  428. // This refusal never reached a category, so `results` is empty and there is
  429. // nothing to re-read. A failure that committed one does invalidate — the
  430. // partial test below covers that side...
  431. expect(keys).not.toContain(JSON.stringify(['spools']));
  432. expect(keys).not.toContain(JSON.stringify(['archives']));
  433. // ...but a failure past the commit resolve writes a "failed" log row, so the
  434. // history is refreshed whatever the outcome.
  435. expect(keys).toContain(JSON.stringify(['github-backup-logs']));
  436. invalidate.mockRestore();
  437. });
  438. // Categories commit as each one finishes, so a run that fails part-way leaves
  439. // the earlier ones on disk and reports them. The modal used to gate the whole
  440. // result panel — and the cache invalidation with it — on `success`, so those
  441. // rows were written, never shown, and never re-read: the app carried on
  442. // displaying pre-restore settings while the database held the restored ones.
  443. const partialRestore = {
  444. success: false,
  445. message: 'database is locked',
  446. log_id: 7,
  447. ref: 'a'.repeat(40),
  448. results: {
  449. archives: { restored: 12, skipped: 0, failed: 0, notes: [] },
  450. settings: { restored: 4, skipped: 1, failed: 0, notes: [] },
  451. },
  452. };
  453. const runRestore = async () => {
  454. const checkboxes = await waitFor(() => screen.getAllByRole('checkbox') as HTMLInputElement[]);
  455. await userEvent.click(checkboxes[1]);
  456. await userEvent.click(screen.getByRole('button', { name: /Restore$/ }));
  457. await waitFor(() => screen.getByText('Restore from backup?'));
  458. const confirmButtons = screen.getAllByRole('button', { name: /Restore$/ });
  459. await userEvent.click(confirmButtons[confirmButtons.length - 1]);
  460. };
  461. it('reports the categories a part-way failure already committed', async () => {
  462. server.use(http.post('/api/v1/github-backup/restore', () => HttpResponse.json(partialRestore)));
  463. render(<GitHubRestoreModal onClose={vi.fn()} />);
  464. await runRestore();
  465. // The tallies are the point: they name what is on disk.
  466. await waitFor(() => expect(screen.getByText('database is locked')).toBeInTheDocument());
  467. expect(screen.getByText(/12 restored/)).toBeInTheDocument();
  468. expect(screen.getByText(/4 restored/)).toBeInTheDocument();
  469. expect(screen.getByText(/The categories listed above finished and are on disk/)).toBeInTheDocument();
  470. // And it must not read as a success — the run did not finish, so the
  471. // warning icon stands in for the green tick.
  472. expect(document.querySelector('svg.text-yellow-500')).toBeInTheDocument();
  473. expect(document.querySelector('svg.text-bambu-green')).not.toBeInTheDocument();
  474. });
  475. it('refreshes the data caches for a part-way failure, because rows landed', async () => {
  476. server.use(http.post('/api/v1/github-backup/restore', () => HttpResponse.json(partialRestore)));
  477. const invalidate = vi.spyOn(QueryClient.prototype, 'invalidateQueries');
  478. render(<GitHubRestoreModal onClose={vi.fn()} />);
  479. await runRestore();
  480. await waitFor(() => screen.getByText('database is locked'));
  481. const keys = invalidate.mock.calls.map((c) => JSON.stringify(c[0]?.queryKey));
  482. expect(keys).toContain(JSON.stringify(['archives']));
  483. expect(keys).toContain(JSON.stringify(['settings']));
  484. invalidate.mockRestore();
  485. });
  486. // A provider-side failure answers 200 with `success: false`; a rejected
  487. // *request* throws in `request()`, leaving `data` undefined. Reading the
  488. // message off `data` alone meant the second kind rendered an empty modal —
  489. // picker holding only "Latest", every category greyed out, no explanation.
  490. it('explains a rejected preview request instead of greying out every category', async () => {
  491. server.use(
  492. http.get('/api/v1/github-backup/restore/preview', () =>
  493. HttpResponse.json({ detail: 'Not authenticated' }, { status: 401 })
  494. )
  495. );
  496. render(<GitHubRestoreModal onClose={vi.fn()} />);
  497. await waitFor(() => {
  498. expect(screen.getByText('Not authenticated')).toBeInTheDocument();
  499. });
  500. // The category list is replaced by the error, not rendered disabled.
  501. expect(screen.queryAllByRole('checkbox')).toHaveLength(0);
  502. });
  503. it('explains a rejected commit-list request', async () => {
  504. server.use(
  505. http.get('/api/v1/github-backup/commits', () => HttpResponse.json({}, { status: 500 }))
  506. );
  507. render(<GitHubRestoreModal onClose={vi.fn()} />);
  508. // No detail in the body, so the generic string carries the message.
  509. await waitFor(() => {
  510. expect(screen.getByText(/Could not read the backup repository|HTTP 500/)).toBeInTheDocument();
  511. });
  512. });
  513. it('closes via the close button', async () => {
  514. const onClose = vi.fn();
  515. render(<GitHubRestoreModal onClose={onClose} />);
  516. await waitFor(() => screen.getByText('Restore from Git Backup'));
  517. await userEvent.click(screen.getByRole('button', { name: 'Close' }));
  518. expect(onClose).toHaveBeenCalled();
  519. });
  520. // A settings restore has to reach the rest of the app. It used to be the
  521. // opposite problem: SettingsPage's debounced auto-save wrote its pre-restore
  522. // form state back over the restore whenever ['settings'] refetched, so this
  523. // modal skipped that invalidation and pinned the cache instead. #2716 fixed
  524. // the page — it now reconciles a moved server snapshot field by field — and
  525. // the workaround came out with this commit.
  526. describe('a settings restore reaches the rest of the app', () => {
  527. /** Runs a restore returning `results`, leaving the modal on its summary. */
  528. async function restoreWith(results: Record<string, unknown>, onClose = vi.fn()) {
  529. server.use(
  530. http.post('/api/v1/github-backup/restore', () =>
  531. HttpResponse.json({
  532. success: true,
  533. message: 'Restored 77 item(s) from aaa1111',
  534. log_id: 7,
  535. ref: mockPreview.ref,
  536. results,
  537. })
  538. )
  539. );
  540. render(<GitHubRestoreModal onClose={onClose} />);
  541. const checkboxes = await waitFor(() => screen.getAllByRole('checkbox') as HTMLInputElement[]);
  542. await userEvent.click(checkboxes[1]);
  543. await userEvent.click(screen.getByRole('button', { name: /Restore$/ }));
  544. await waitFor(() => screen.getByText('Restore from backup?'));
  545. const confirmButtons = screen.getAllByRole('button', { name: /Restore$/ });
  546. await userEvent.click(confirmButtons[confirmButtons.length - 1]);
  547. await waitFor(() => screen.getByText('Restored 77 item(s) from aaa1111'));
  548. return onClose;
  549. }
  550. /** Replaces window.location with a reload spy for the duration of a test. */
  551. function stubReload() {
  552. const original = window.location;
  553. const reload = vi.fn();
  554. Object.defineProperty(window, 'location', {
  555. configurable: true,
  556. value: { ...original, reload },
  557. });
  558. return {
  559. reload,
  560. restore: () =>
  561. Object.defineProperty(window, 'location', { configurable: true, value: original }),
  562. };
  563. }
  564. it('invalidates the settings query alongside the other rewritten caches', async () => {
  565. const invalidate = vi.spyOn(QueryClient.prototype, 'invalidateQueries');
  566. await restoreWith({ settings: { restored: 77, skipped: 3, failed: 0, notes: [] } });
  567. const keys = invalidate.mock.calls.map((c) => JSON.stringify(c[0]?.queryKey));
  568. expect(keys).toContain(JSON.stringify(['spools']));
  569. expect(keys).toContain(JSON.stringify(['settings']));
  570. invalidate.mockRestore();
  571. });
  572. it('reloads instead of merely closing after a settings restore', async () => {
  573. const loc = stubReload();
  574. try {
  575. const onClose = await restoreWith({
  576. settings: { restored: 77, skipped: 3, failed: 0, notes: [] },
  577. });
  578. const closeButtons = screen.getAllByRole('button', { name: 'Close' });
  579. await userEvent.click(closeButtons[closeButtons.length - 1]);
  580. expect(loc.reload).toHaveBeenCalled();
  581. // Invalidating ['settings'] only resyncs what reads that query. The
  582. // interface language and the auth state do not, so closing in place
  583. // would leave both showing their pre-restore values.
  584. expect(onClose).not.toHaveBeenCalled();
  585. } finally {
  586. loc.restore();
  587. }
  588. });
  589. it('closes normally when settings were not part of the restore', async () => {
  590. const loc = stubReload();
  591. try {
  592. const onClose = await restoreWith({
  593. spools: { restored: 4, skipped: 0, failed: 0, notes: [] },
  594. });
  595. const closeButtons = screen.getAllByRole('button', { name: 'Close' });
  596. await userEvent.click(closeButtons[closeButtons.length - 1]);
  597. expect(onClose).toHaveBeenCalled();
  598. expect(loc.reload).not.toHaveBeenCalled();
  599. } finally {
  600. loc.restore();
  601. }
  602. });
  603. });
  604. });