GitHubRestoreModal.test.tsx 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594
  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, useQuery, useQueryClient } 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. categories: [
  38. { category: 'archives', available: true, item_count: 30, detail: 'Metadata only' },
  39. { category: 'spools', available: true, item_count: 4, detail: null },
  40. { category: 'settings', available: true, item_count: 12, detail: null },
  41. { category: 'kprofiles', available: false, item_count: 0, detail: 'Not present in this backup commit' },
  42. ],
  43. };
  44. type JsonBody = Record<string, unknown>;
  45. function mockEndpoints(overrides: { preview?: JsonBody; commits?: JsonBody } = {}) {
  46. server.use(
  47. http.get('/api/v1/github-backup/commits', () =>
  48. HttpResponse.json(overrides.commits ?? (mockCommits as unknown as JsonBody))
  49. ),
  50. http.get('/api/v1/github-backup/restore/preview', () =>
  51. HttpResponse.json(overrides.preview ?? (mockPreview as unknown as JsonBody))
  52. ),
  53. );
  54. }
  55. describe('GitHubRestoreModal', () => {
  56. beforeEach(() => {
  57. vi.clearAllMocks();
  58. mockEndpoints();
  59. });
  60. it('renders the title and commit picker', async () => {
  61. render(<GitHubRestoreModal onClose={vi.fn()} />);
  62. await waitFor(() => {
  63. expect(screen.getByText('Restore from Git Backup')).toBeInTheDocument();
  64. });
  65. expect(screen.getByLabelText('Backup commit')).toBeInTheDocument();
  66. });
  67. it('defaults to the latest commit and lists recent commits', async () => {
  68. render(<GitHubRestoreModal onClose={vi.fn()} />);
  69. const select = (await screen.findByLabelText('Backup commit')) as HTMLSelectElement;
  70. expect(select.value).toBe('HEAD');
  71. await waitFor(() => {
  72. expect(screen.getByText(/Latest backup/)).toBeInTheDocument();
  73. });
  74. // Commits are labelled by short SHA.
  75. await waitFor(() => {
  76. expect(screen.getByRole('option', { name: /aaa1111/ })).toBeInTheDocument();
  77. expect(screen.getByRole('option', { name: /bbb2222/ })).toBeInTheDocument();
  78. });
  79. });
  80. it('shows item counts for categories present in the commit', async () => {
  81. render(<GitHubRestoreModal onClose={vi.fn()} />);
  82. await waitFor(() => {
  83. expect(screen.getByText('30 in backup')).toBeInTheDocument();
  84. });
  85. expect(screen.getByText('4 in backup')).toBeInTheDocument();
  86. expect(screen.getByText('12 in backup')).toBeInTheDocument();
  87. });
  88. it('disables a category that is absent from the commit', async () => {
  89. render(<GitHubRestoreModal onClose={vi.fn()} />);
  90. await waitFor(() => {
  91. expect(screen.getByText('Not present in this backup commit')).toBeInTheDocument();
  92. });
  93. const checkboxes = screen.getAllByRole('checkbox') as HTMLInputElement[];
  94. // Four categories in fixed order: archives, spools, settings, kprofiles.
  95. expect(checkboxes).toHaveLength(4);
  96. expect(checkboxes[3].disabled).toBe(true);
  97. expect(checkboxes[0].disabled).toBe(false);
  98. });
  99. it('keeps Restore disabled until a category is selected', async () => {
  100. render(<GitHubRestoreModal onClose={vi.fn()} />);
  101. const restoreButton = await screen.findByRole('button', { name: /Restore$/ });
  102. expect(restoreButton).toBeDisabled();
  103. // Wait for the preview to populate the category list before selecting.
  104. const checkboxes = await waitFor(() => {
  105. const found = screen.getAllByRole('checkbox') as HTMLInputElement[];
  106. expect(found).toHaveLength(4);
  107. return found;
  108. });
  109. await userEvent.click(checkboxes[1]);
  110. await waitFor(() => expect(restoreButton).not.toBeDisabled());
  111. expect(screen.getByText('1 selected')).toBeInTheDocument();
  112. });
  113. it('requires confirmation before sending the restore', async () => {
  114. let restoreCalls = 0;
  115. server.use(
  116. http.post('/api/v1/github-backup/restore', async () => {
  117. restoreCalls += 1;
  118. return HttpResponse.json({
  119. success: true,
  120. message: 'Restored 4 item(s) from aaa1111',
  121. log_id: 3,
  122. ref: mockPreview.ref,
  123. results: { spools: { restored: 4, skipped: 1, failed: 0, notes: [] } },
  124. });
  125. })
  126. );
  127. render(<GitHubRestoreModal onClose={vi.fn()} />);
  128. const checkboxes = await waitFor(() => screen.getAllByRole('checkbox') as HTMLInputElement[]);
  129. await userEvent.click(checkboxes[1]);
  130. await userEvent.click(screen.getByRole('button', { name: /Restore$/ }));
  131. // Confirm dialog appears; nothing sent yet.
  132. await waitFor(() => {
  133. expect(screen.getByText('Restore from backup?')).toBeInTheDocument();
  134. });
  135. expect(restoreCalls).toBe(0);
  136. });
  137. it('sends the selected categories and shows per-category results', async () => {
  138. let body: Record<string, unknown> | null = null;
  139. server.use(
  140. http.post('/api/v1/github-backup/restore', async ({ request }) => {
  141. body = (await request.json()) as Record<string, unknown>;
  142. return HttpResponse.json({
  143. success: true,
  144. message: 'Restored 4 item(s) from aaa1111',
  145. log_id: 3,
  146. ref: mockPreview.ref,
  147. results: {
  148. spools: { restored: 4, skipped: 1, failed: 0, notes: ['1 usage record(s) skipped'] },
  149. },
  150. });
  151. })
  152. );
  153. render(<GitHubRestoreModal onClose={vi.fn()} />);
  154. const checkboxes = await waitFor(() => screen.getAllByRole('checkbox') as HTMLInputElement[]);
  155. await userEvent.click(checkboxes[1]);
  156. await userEvent.click(screen.getByRole('button', { name: /Restore$/ }));
  157. await waitFor(() => screen.getByText('Restore from backup?'));
  158. const confirmButtons = screen.getAllByRole('button', { name: /Restore$/ });
  159. await userEvent.click(confirmButtons[confirmButtons.length - 1]);
  160. await waitFor(() => {
  161. expect(screen.getByText('Restored 4 item(s) from aaa1111')).toBeInTheDocument();
  162. });
  163. // The commit posted is the sha the preview resolved to, not the symbolic
  164. // 'HEAD' the picker defaults to: re-resolving server-side would restore a
  165. // backup that landed after the preview the user actually approved.
  166. expect(body).toMatchObject({
  167. categories: ['spools'],
  168. overwrite_existing: false,
  169. ref: mockPreview.ref,
  170. });
  171. expect(screen.getByText('4 restored, 1 skipped, 0 failed')).toBeInTheDocument();
  172. expect(screen.getByText('1 usage record(s) skipped')).toBeInTheDocument();
  173. });
  174. it('drops the selection while a newly-picked commit is still being inspected', async () => {
  175. // Switching commits keeps `selected` (it is only pruned once the new preview
  176. // lands), so the footer must not keep counting it: the categories belong to
  177. // the commit that was switched away from, and the user has not seen an item
  178. // count for the new one.
  179. let previewCalls = 0;
  180. server.use(
  181. http.get('/api/v1/github-backup/restore/preview', async () => {
  182. previewCalls += 1;
  183. // The second commit's preview never resolves, holding the modal in the
  184. // in-flight state the assertions below describe.
  185. if (previewCalls > 1) await delay('infinite');
  186. return HttpResponse.json(mockPreview as unknown as JsonBody);
  187. })
  188. );
  189. render(<GitHubRestoreModal onClose={vi.fn()} />);
  190. const checkboxes = await waitFor(() => screen.getAllByRole('checkbox') as HTMLInputElement[]);
  191. await userEvent.click(checkboxes[1]);
  192. await waitFor(() => expect(screen.getByText('1 selected')).toBeInTheDocument());
  193. await userEvent.selectOptions(screen.getByLabelText('Backup commit'), mockCommits.commits[1].sha);
  194. await waitFor(() => expect(screen.getByText('Reading backup contents...')).toBeInTheDocument());
  195. expect(screen.getByText('0 selected')).toBeInTheDocument();
  196. expect(screen.getByRole('button', { name: /Restore$/ })).toBeDisabled();
  197. });
  198. it('sends overwrite_existing when the toggle is on', async () => {
  199. let body: Record<string, unknown> | null = null;
  200. server.use(
  201. http.post('/api/v1/github-backup/restore', async ({ request }) => {
  202. body = (await request.json()) as Record<string, unknown>;
  203. return HttpResponse.json({ success: true, message: 'done', log_id: 1, ref: 'x', results: {} });
  204. })
  205. );
  206. render(<GitHubRestoreModal onClose={vi.fn()} />);
  207. const checkboxes = await waitFor(() => screen.getAllByRole('checkbox') as HTMLInputElement[]);
  208. await userEvent.click(checkboxes[1]);
  209. await userEvent.click(screen.getByRole('switch'));
  210. await userEvent.click(screen.getByRole('button', { name: /Restore$/ }));
  211. await waitFor(() => screen.getByText('Restore from backup?'));
  212. const confirmButtons = screen.getAllByRole('button', { name: /Restore$/ });
  213. await userEvent.click(confirmButtons[confirmButtons.length - 1]);
  214. await waitFor(() => expect(body).toMatchObject({ overwrite_existing: true }));
  215. });
  216. it('warns more strongly when overwrite is enabled', async () => {
  217. render(<GitHubRestoreModal onClose={vi.fn()} />);
  218. const checkboxes = await waitFor(() => screen.getAllByRole('checkbox') as HTMLInputElement[]);
  219. await userEvent.click(checkboxes[1]);
  220. await userEvent.click(screen.getByRole('switch'));
  221. await userEvent.click(screen.getByRole('button', { name: /Restore$/ }));
  222. await waitFor(() => {
  223. expect(screen.getByText(/This cannot be undone/)).toBeInTheDocument();
  224. });
  225. });
  226. it('surfaces a preview failure instead of an empty category list', async () => {
  227. mockEndpoints({
  228. preview: {
  229. success: false,
  230. message: 'Commit or tree deadbee not found in the repository',
  231. ref: 'deadbee',
  232. categories: [],
  233. },
  234. });
  235. render(<GitHubRestoreModal onClose={vi.fn()} />);
  236. await waitFor(() => {
  237. expect(screen.getByText('Commit or tree deadbee not found in the repository')).toBeInTheDocument();
  238. });
  239. expect(screen.queryAllByRole('checkbox')).toHaveLength(0);
  240. });
  241. it('surfaces a commit listing failure', async () => {
  242. mockEndpoints({
  243. commits: { success: false, message: 'Invalid access token', branch: 'main', commits: [] },
  244. });
  245. render(<GitHubRestoreModal onClose={vi.fn()} />);
  246. await waitFor(() => {
  247. expect(screen.getByText('Invalid access token')).toBeInTheDocument();
  248. });
  249. });
  250. // A refused restore answers 200 with `success: false`, and two of the five
  251. // refusals are ordinary conditions rather than errors — a restore already
  252. // running, and a backup mid-flight. Rendering the result panel for those put a
  253. // green tick and "reload so the restored data appears" above a message saying
  254. // nothing had been restored, i.e. a failure that read as a success.
  255. it('reports a backend refusal such as the backup/restore mutex', async () => {
  256. server.use(
  257. http.post('/api/v1/github-backup/restore', () =>
  258. HttpResponse.json({
  259. success: false,
  260. message: 'A backup is currently running. Wait for it to finish before restoring.',
  261. results: {},
  262. })
  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 userEvent.click(screen.getByRole('button', { name: /Restore$/ }));
  269. await waitFor(() => screen.getByText('Restore from backup?'));
  270. const confirmButtons = screen.getAllByRole('button', { name: /Restore$/ });
  271. await userEvent.click(confirmButtons[confirmButtons.length - 1]);
  272. await waitFor(() => {
  273. expect(
  274. screen.getByText('A backup is currently running. Wait for it to finish before restoring.')
  275. ).toBeInTheDocument();
  276. });
  277. // Not the success panel: no reload hint, no "Reload now", and the form is
  278. // still there so the user can retry once the backup finishes.
  279. expect(screen.queryByText(/Reload Bambuddy so the restored data appears/)).not.toBeInTheDocument();
  280. expect(screen.queryByRole('button', { name: /Reload now/ })).not.toBeInTheDocument();
  281. expect(screen.getByLabelText('Backup commit')).toBeInTheDocument();
  282. expect(screen.getByRole('button', { name: /Restore$/ })).toBeEnabled();
  283. });
  284. it('does not refresh the data caches when a restore was refused', async () => {
  285. server.use(
  286. http.post('/api/v1/github-backup/restore', () =>
  287. HttpResponse.json({ success: false, message: 'A restore is already running', results: {} })
  288. )
  289. );
  290. const invalidate = vi.spyOn(QueryClient.prototype, 'invalidateQueries');
  291. render(<GitHubRestoreModal onClose={vi.fn()} />);
  292. const checkboxes = await waitFor(() => screen.getAllByRole('checkbox') as HTMLInputElement[]);
  293. await userEvent.click(checkboxes[1]);
  294. await userEvent.click(screen.getByRole('button', { name: /Restore$/ }));
  295. await waitFor(() => screen.getByText('Restore from backup?'));
  296. const confirmButtons = screen.getAllByRole('button', { name: /Restore$/ });
  297. await userEvent.click(confirmButtons[confirmButtons.length - 1]);
  298. await waitFor(() => screen.getByText('A restore is already running'));
  299. const keys = invalidate.mock.calls.map((c) => JSON.stringify(c[0]?.queryKey));
  300. // Nothing was written, so nothing to re-read...
  301. expect(keys).not.toContain(JSON.stringify(['spools']));
  302. expect(keys).not.toContain(JSON.stringify(['archives']));
  303. // ...but a failure past the commit resolve writes a "failed" log row, so the
  304. // history is refreshed whatever the outcome.
  305. expect(keys).toContain(JSON.stringify(['github-backup-logs']));
  306. invalidate.mockRestore();
  307. });
  308. // A provider-side failure answers 200 with `success: false`; a rejected
  309. // *request* throws in `request()`, leaving `data` undefined. Reading the
  310. // message off `data` alone meant the second kind rendered an empty modal —
  311. // picker holding only "Latest", every category greyed out, no explanation.
  312. it('explains a rejected preview request instead of greying out every category', async () => {
  313. server.use(
  314. http.get('/api/v1/github-backup/restore/preview', () =>
  315. HttpResponse.json({ detail: 'Not authenticated' }, { status: 401 })
  316. )
  317. );
  318. render(<GitHubRestoreModal onClose={vi.fn()} />);
  319. await waitFor(() => {
  320. expect(screen.getByText('Not authenticated')).toBeInTheDocument();
  321. });
  322. // The category list is replaced by the error, not rendered disabled.
  323. expect(screen.queryAllByRole('checkbox')).toHaveLength(0);
  324. });
  325. it('explains a rejected commit-list request', async () => {
  326. server.use(
  327. http.get('/api/v1/github-backup/commits', () => HttpResponse.json({}, { status: 500 }))
  328. );
  329. render(<GitHubRestoreModal onClose={vi.fn()} />);
  330. // No detail in the body, so the generic string carries the message.
  331. await waitFor(() => {
  332. expect(screen.getByText(/Could not read the backup repository|HTTP 500/)).toBeInTheDocument();
  333. });
  334. });
  335. it('closes via the close button', async () => {
  336. const onClose = vi.fn();
  337. render(<GitHubRestoreModal onClose={onClose} />);
  338. await waitFor(() => screen.getByText('Restore from Git Backup'));
  339. await userEvent.click(screen.getByRole('button', { name: 'Close' }));
  340. expect(onClose).toHaveBeenCalled();
  341. });
  342. // Regression guard for the settings clobber found in manual testing (#2656).
  343. // SettingsPage — which renders this modal — holds a `localSettings` copy of the
  344. // form state and a debounced effect that PATCHes it back whenever the server
  345. // copy differs. Refetching ['settings'] here therefore made the page overwrite
  346. // the restore with the pre-restore values, silently, ~500 ms later. 75 of the
  347. // ~80 keys in a real backup sit in that save payload, so a restore reported as
  348. // "77 restored, 0 failed" left almost nothing behind.
  349. describe('settings restore must not be undone by SettingsPage', () => {
  350. /** Runs a restore returning `results`, leaving the modal on its summary. */
  351. async function restoreWith(results: Record<string, unknown>, onClose = vi.fn()) {
  352. server.use(
  353. http.post('/api/v1/github-backup/restore', () =>
  354. HttpResponse.json({
  355. success: true,
  356. message: 'Restored 77 item(s) from aaa1111',
  357. log_id: 7,
  358. ref: mockPreview.ref,
  359. results,
  360. })
  361. )
  362. );
  363. render(<GitHubRestoreModal onClose={onClose} />);
  364. const checkboxes = await waitFor(() => screen.getAllByRole('checkbox') as HTMLInputElement[]);
  365. await userEvent.click(checkboxes[1]);
  366. await userEvent.click(screen.getByRole('button', { name: /Restore$/ }));
  367. await waitFor(() => screen.getByText('Restore from backup?'));
  368. const confirmButtons = screen.getAllByRole('button', { name: /Restore$/ });
  369. await userEvent.click(confirmButtons[confirmButtons.length - 1]);
  370. await waitFor(() => screen.getByText('Restored 77 item(s) from aaa1111'));
  371. return onClose;
  372. }
  373. /** Replaces window.location with a reload spy for the duration of a test. */
  374. function stubReload() {
  375. const original = window.location;
  376. const reload = vi.fn();
  377. Object.defineProperty(window, 'location', {
  378. configurable: true,
  379. value: { ...original, reload },
  380. });
  381. return {
  382. reload,
  383. restore: () =>
  384. Object.defineProperty(window, 'location', { configurable: true, value: original }),
  385. };
  386. }
  387. /**
  388. * Stands in for SettingsPage: hands the test the provider's QueryClient and
  389. * keeps an observer on ['settings'] for as long as the modal is mounted, so
  390. * the entry survives the test client's `gcTime: 0`. Disabled, because these
  391. * tests write the cache directly rather than fetching it.
  392. */
  393. function makeSettingsProbe(capture: (client: QueryClient) => void) {
  394. return function SettingsProbe() {
  395. capture(useQueryClient());
  396. useQuery({ queryKey: ['settings'], queryFn: async () => null, enabled: false });
  397. return null;
  398. };
  399. }
  400. it('never invalidates the settings query', async () => {
  401. const invalidate = vi.spyOn(QueryClient.prototype, 'invalidateQueries');
  402. await restoreWith({ settings: { restored: 77, skipped: 3, failed: 0, notes: [] } });
  403. const keys = invalidate.mock.calls.map((c) => JSON.stringify(c[0]?.queryKey));
  404. // The caches this restore genuinely rewrites are still refreshed...
  405. expect(keys).toContain(JSON.stringify(['spools']));
  406. // ...but ['settings'] must not be, or the page writes the old values back.
  407. expect(keys).not.toContain(JSON.stringify(['settings']));
  408. invalidate.mockRestore();
  409. });
  410. it('reloads instead of merely closing after a settings restore', async () => {
  411. const loc = stubReload();
  412. try {
  413. const onClose = await restoreWith({
  414. settings: { restored: 77, skipped: 3, failed: 0, notes: [] },
  415. });
  416. const closeButtons = screen.getAllByRole('button', { name: 'Close' });
  417. await userEvent.click(closeButtons[closeButtons.length - 1]);
  418. expect(loc.reload).toHaveBeenCalled();
  419. // Closing in place would leave SettingsPage mounted and armed.
  420. expect(onClose).not.toHaveBeenCalled();
  421. } finally {
  422. loc.restore();
  423. }
  424. });
  425. // Not invalidating ['settings'] only closes the refetch *this* modal caused.
  426. // The query still refetches on window focus and on reconnect — both default
  427. // to true, and ['settings'] has other always-mounted observers — and a
  428. // refetch landing while the result panel is up puts the restored values in
  429. // the cache next to SettingsPage's pre-restore `localSettings`, which is
  430. // precisely the state its debounced effect writes back. So the cache is
  431. // pinned to the copy the page already agrees with until the exit reload.
  432. it('pins the settings cache against a background refetch landing under the result panel', async () => {
  433. const preRestore = { currency: 'EUR' };
  434. let client: QueryClient | null = null;
  435. const Probe = makeSettingsProbe((c) => {
  436. client = c;
  437. });
  438. server.use(
  439. http.post('/api/v1/github-backup/restore', () =>
  440. HttpResponse.json({
  441. success: true,
  442. message: 'Restored 77 item(s) from aaa1111',
  443. log_id: 7,
  444. ref: mockPreview.ref,
  445. results: { settings: { restored: 77, skipped: 3, failed: 0, notes: [] } },
  446. })
  447. )
  448. );
  449. render(
  450. <>
  451. <Probe />
  452. <GitHubRestoreModal onClose={vi.fn()} />
  453. </>
  454. );
  455. // The copy SettingsPage's form state was built from.
  456. client!.setQueryData(['settings'], preRestore);
  457. const checkboxes = await waitFor(() => screen.getAllByRole('checkbox') as HTMLInputElement[]);
  458. await userEvent.click(checkboxes[2]);
  459. await userEvent.click(screen.getByRole('button', { name: /Restore$/ }));
  460. await waitFor(() => screen.getByText('Restore from backup?'));
  461. const confirmButtons = screen.getAllByRole('button', { name: /Restore$/ });
  462. await userEvent.click(confirmButtons[confirmButtons.length - 1]);
  463. await waitFor(() => screen.getByText('Restored 77 item(s) from aaa1111'));
  464. // A focus/reconnect refetch lands the restored server values.
  465. client!.setQueryData(['settings'], { currency: 'USD' });
  466. // Pinned back, so SettingsPage sees no divergence and saves nothing.
  467. expect(client!.getQueryData(['settings'])).toEqual(preRestore);
  468. });
  469. it('leaves the settings cache alone when settings were not restored', async () => {
  470. let client: QueryClient | null = null;
  471. const Probe = makeSettingsProbe((c) => {
  472. client = c;
  473. });
  474. server.use(
  475. http.post('/api/v1/github-backup/restore', () =>
  476. HttpResponse.json({
  477. success: true,
  478. message: 'Restored 4 item(s) from aaa1111',
  479. log_id: 8,
  480. ref: mockPreview.ref,
  481. results: { spools: { restored: 4, skipped: 0, failed: 0, notes: [] } },
  482. })
  483. )
  484. );
  485. render(
  486. <>
  487. <Probe />
  488. <GitHubRestoreModal onClose={vi.fn()} />
  489. </>
  490. );
  491. client!.setQueryData(['settings'], { currency: 'EUR' });
  492. const checkboxes = await waitFor(() => screen.getAllByRole('checkbox') as HTMLInputElement[]);
  493. await userEvent.click(checkboxes[1]);
  494. await userEvent.click(screen.getByRole('button', { name: /Restore$/ }));
  495. await waitFor(() => screen.getByText('Restore from backup?'));
  496. const confirmButtons = screen.getAllByRole('button', { name: /Restore$/ });
  497. await userEvent.click(confirmButtons[confirmButtons.length - 1]);
  498. await waitFor(() => screen.getByText('Restored 4 item(s) from aaa1111'));
  499. const fresh = { currency: 'USD' };
  500. client!.setQueryData(['settings'], fresh);
  501. // No settings were touched, so there is nothing to protect and normal
  502. // refetching must keep working.
  503. expect(client!.getQueryData(['settings'])).toEqual(fresh);
  504. });
  505. it('closes normally when settings were not part of the restore', async () => {
  506. const loc = stubReload();
  507. try {
  508. const onClose = await restoreWith({
  509. spools: { restored: 4, skipped: 0, failed: 0, notes: [] },
  510. });
  511. const closeButtons = screen.getAllByRole('button', { name: 'Close' });
  512. await userEvent.click(closeButtons[closeButtons.length - 1]);
  513. expect(onClose).toHaveBeenCalled();
  514. expect(loc.reload).not.toHaveBeenCalled();
  515. } finally {
  516. loc.restore();
  517. }
  518. });
  519. });
  520. });