mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 21:46:49 +00:00
fe5c6f182e
Two separate reasons v4.0.0 (#1719) is red. The SonarQube workflow killed all 616 client test files with "Cannot find module '@testing-library/dom'". That package is a peer dependency of @testing-library/react and not a direct dependency of the client, and the step that installs @swc/core's Linux binary passes --legacy-peer-deps, which tells npm not to put peer dependencies back when it re-resolves the tree. In test.yml that step lives in the server job, which never runs client tests, so folding the jobs into one (#2039) is what brought the two together. It now installs alongside the swc binary, with its version read from the peer range rather than pinned, and a one-line resolve check right after fails fast with the module name instead of six hundred identical failures twenty minutes later. MAdminStoragePanel's FE-MOB-MSTOR-015 was a genuine race in the test, not in the panel. Its POST handler incremented postCount, then awaited request.json(), and only then pushed to migrationPosts — so between the two there was a window where one POST had been counted but migrationPosts was still empty. The GET handler reports `migrations: []` for an empty migrationPosts, i.e. "nothing running", so a poll landing in that window told the queue it was free to start the second migration, and the waitFor(postCount === 1) above could resolve inside it too. Parsing before counting closes it. Verified by running the whole mobile suite at --maxWorkers=8, where it used to surface.
440 lines
23 KiB
TypeScript
440 lines
23 KiB
TypeScript
import { http, HttpResponse } from 'msw';
|
|
import { describe, expect, it } from 'vitest';
|
|
import { MASKED_SETTING_VALUE, type StorageAdminState, type StorageCategory, type StorageConfig } from '@trek/shared';
|
|
import { server } from '../../../helpers/msw/server';
|
|
import { fireEvent, render, screen, waitFor, within } from '../../../helpers/render';
|
|
import { ToastContainer } from '../../../../src/components/shared/Toast';
|
|
import MAdminStoragePanel from '../../../../src/mobile/screens/admin/MAdminStoragePanel';
|
|
|
|
function baseState(overrides: Partial<StorageAdminState> = {}): StorageAdminState {
|
|
return {
|
|
backends: [
|
|
{ name: 'uploads-local', type: 'local', source: 'built-in', options: { root: '/data/uploads' }, categories: ['files', 'journey', 'covers', 'avatars', 'photos-google', 'photos-trek'] },
|
|
{ name: 'backups-local', type: 'local', source: 'built-in', options: { root: '/data/backups' }, categories: ['backups'] },
|
|
{
|
|
name: 'off-box', type: 's3', source: 'settings',
|
|
options: { endpoint: 'http://127.0.0.1:9000', bucket: 'trek', accessKeyId: 'ak', secretAccessKey: MASKED_SETTING_VALUE, region: 'us-east-1', keyPrefix: '', retries: 1, timeoutMs: 30000 },
|
|
categories: [],
|
|
},
|
|
],
|
|
categories: {
|
|
files: { backend: 'uploads-local', source: 'default' },
|
|
journey: { backend: 'uploads-local', source: 'default' },
|
|
covers: { backend: 'uploads-local', source: 'default' },
|
|
avatars: { backend: 'uploads-local', source: 'default' },
|
|
places: { backend: 'uploads-local', source: 'default' },
|
|
'photos-google': { backend: 'uploads-local', source: 'default' },
|
|
'photos-trek': { backend: 'uploads-local', source: 'default' },
|
|
backups: { backend: 'backups-local', source: 'default' },
|
|
},
|
|
health: { replicaFailures: [] },
|
|
seedFilePresent: false,
|
|
usage: null,
|
|
backfills: [],
|
|
migrations: [],
|
|
version: 0,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function mirroredState(): StorageAdminState {
|
|
const state = baseState();
|
|
state.backends.push({
|
|
name: 'mirror', type: 'mirror', source: 'settings',
|
|
options: { primary: 'backups-local', replicas: ['off-box'] }, categories: ['backups'],
|
|
});
|
|
state.categories.backups = { backend: 'mirror', source: 'settings' };
|
|
return state;
|
|
}
|
|
|
|
async function renderPanel(state: StorageAdminState = baseState()) {
|
|
server.use(http.get('/api/admin/storage', () => HttpResponse.json(state)));
|
|
render(
|
|
<>
|
|
<ToastContainer />
|
|
<MAdminStoragePanel />
|
|
</>,
|
|
);
|
|
await waitFor(() => expect(screen.getByText('Backends')).toBeInTheDocument());
|
|
}
|
|
|
|
describe('MAdminStoragePanel', () => {
|
|
it('FE-MOB-MSTOR-001: renders every backend with type and source, env rows read-only', async () => {
|
|
await renderPanel();
|
|
const row = screen.getByTestId('m-storage-backend-off-box');
|
|
expect(within(row).getByText('S3')).toBeInTheDocument();
|
|
expect(within(row).getByText('Settings')).toBeInTheDocument();
|
|
expect(screen.getByText('No replica failures recorded.')).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-MOB-MSTOR-002: reassigning a category via the picker sheet warns and saves through one PUT', async () => {
|
|
let putBody: unknown;
|
|
await renderPanel();
|
|
server.use(
|
|
http.put('/api/admin/storage', async ({ request }) => {
|
|
putBody = await request.json();
|
|
return HttpResponse.json(baseState());
|
|
}),
|
|
);
|
|
fireEvent.click(within(screen.getByTestId('m-storage-category-files')).getByRole('button'));
|
|
fireEvent.click(screen.getByRole('button', { name: 'off-box' })); // picker option; selecting closes
|
|
expect(screen.getByText(/Existing objects do not move/)).toBeInTheDocument();
|
|
fireEvent.click(screen.getByRole('button', { name: 'Save changes' }));
|
|
// Usage was never scanned (baseState().usage === null) — the migrate
|
|
// prompt fires; this test is about the picker + PUT shape, not the
|
|
// prompt, so route new writes only (today's save behavior, unstripped).
|
|
await screen.findByRole('alertdialog');
|
|
fireEvent.click(screen.getByRole('button', { name: 'Just route new writes' }));
|
|
await screen.findByText('Storage configuration saved');
|
|
expect((putBody as { categories: Record<string, string> }).categories.files).toBe('off-box');
|
|
});
|
|
|
|
it('FE-MOB-MSTOR-003: a typed plaintext secret keeps Apply offered (no encryption-key gate)', async () => {
|
|
await renderPanel();
|
|
fireEvent.click(within(screen.getByTestId('m-storage-backend-off-box')).getByRole('button', { name: 'Edit' }));
|
|
fireEvent.change(screen.getByPlaceholderText('us-east-1'), { target: { value: 'eu-west-1' } }); // sanity: form is open
|
|
const secret = screen.getByDisplayValue(MASKED_SETTING_VALUE);
|
|
fireEvent.change(secret, { target: { value: 'sk-new' } });
|
|
expect(screen.getByRole('button', { name: 'Apply' })).toBeInTheDocument();
|
|
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-MOB-MSTOR-004: a 400 renders the server message verbatim', async () => {
|
|
await renderPanel();
|
|
server.use(http.put('/api/admin/storage', () => HttpResponse.json({ error: 'registry says no' }, { status: 400 })));
|
|
fireEvent.click(within(screen.getByTestId('m-storage-category-files')).getByRole('button'));
|
|
fireEvent.click(screen.getByRole('button', { name: 'off-box' }));
|
|
fireEvent.click(screen.getByRole('button', { name: 'Save changes' }));
|
|
// Usage was never scanned — the migrate prompt fires first; route new
|
|
// writes only, matching this test's focus on the save-error rendering.
|
|
await screen.findByRole('alertdialog');
|
|
fireEvent.click(screen.getByRole('button', { name: 'Just route new writes' }));
|
|
expect(await screen.findByText('registry says no')).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-MOB-MSTOR-005: renaming onto another existing backend warns and blocks Apply', async () => {
|
|
await renderPanel();
|
|
fireEvent.click(within(screen.getByTestId('m-storage-backend-off-box')).getByRole('button', { name: 'Edit' }));
|
|
const nameInput = screen.getByDisplayValue('off-box');
|
|
fireEvent.change(nameInput, { target: { value: 'uploads-local' } });
|
|
expect(screen.getByText(/A backend named uploads-local already exists/)).toBeInTheDocument();
|
|
expect(screen.getByRole('button', { name: 'Apply' })).toBeDisabled();
|
|
});
|
|
|
|
it('FE-MOB-MSTOR-006: mirrors fold — no mirror card, primary and replica decorated', async () => {
|
|
await renderPanel(mirroredState());
|
|
expect(screen.queryByTestId('m-storage-backend-mirror')).not.toBeInTheDocument();
|
|
const primary = screen.getByTestId('m-storage-backend-backups-local');
|
|
expect(within(primary).getByText('Mirrored to: off-box')).toBeInTheDocument();
|
|
const replica = screen.getByTestId('m-storage-backend-off-box');
|
|
expect(within(replica).getByText('Replica of: backups-local')).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-MOB-MSTOR-007: toggling a target on a primary synthesizes the mirror in the PUT', async () => {
|
|
let putBody: unknown;
|
|
await renderPanel();
|
|
server.use(
|
|
http.put('/api/admin/storage', async ({ request }) => {
|
|
putBody = await request.json();
|
|
return HttpResponse.json(baseState());
|
|
}),
|
|
);
|
|
fireEvent.click(within(screen.getByTestId('m-storage-backend-backups-local')).getByRole('button', { name: 'Edit' }));
|
|
fireEvent.click(screen.getByRole('switch', { name: 'off-box' }));
|
|
expect(screen.getByText(/slows every upload/)).toBeInTheDocument();
|
|
fireEvent.click(screen.getByRole('button', { name: 'Apply' }));
|
|
fireEvent.click(screen.getByRole('button', { name: 'Save changes' }));
|
|
await screen.findByText('Storage configuration saved');
|
|
const body = putBody as { backends: Array<{ name: string; type: string; options: unknown }>; categories: Record<string, string> };
|
|
expect(body.backends.find((b) => b.name === 'backups-local-mirror')!.options).toEqual({
|
|
primary: 'backups-local', replicas: ['off-box'],
|
|
});
|
|
expect(body.categories.backups).toBe('backups-local-mirror');
|
|
});
|
|
|
|
it('FE-MOB-MSTOR-008: the category picker deals in primaries and warns on cache categories', async () => {
|
|
let putBody: unknown;
|
|
await renderPanel(mirroredState());
|
|
server.use(
|
|
http.put('/api/admin/storage', async ({ request }) => {
|
|
putBody = await request.json();
|
|
return HttpResponse.json(mirroredState());
|
|
}),
|
|
);
|
|
fireEvent.click(within(screen.getByTestId('m-storage-category-places')).getByRole('button'));
|
|
fireEvent.click(within(screen.getByRole('dialog', { name: 'Place images' })).getByRole('button', { name: 'backups-local' })); // picker option = primary name
|
|
// getByText(/re-fetchable/) is now ambiguous — the photos-google/photos-trek category
|
|
// descriptions also say "re-fetchable". The cache warning is the only role="note" on
|
|
// screen here (the mirror-targets latency note only renders while a form is open).
|
|
expect(screen.getByRole('note')).toHaveTextContent(/re-fetchable/);
|
|
fireEvent.click(screen.getByRole('button', { name: 'Save changes' }));
|
|
// Usage was never scanned — the migrate prompt fires first; route new
|
|
// writes only, matching this test's focus on the picker/cache-warning.
|
|
await screen.findByRole('alertdialog');
|
|
fireEvent.click(screen.getByRole('button', { name: 'Just route new writes' }));
|
|
await screen.findByText('Storage configuration saved');
|
|
expect((putBody as { categories: Record<string, string> }).categories.places).toBe('mirror');
|
|
});
|
|
|
|
it('FE-MOB-MSTOR-009: category rows show display name, id badge and description; photos is gone', async () => {
|
|
await renderPanel();
|
|
const files = screen.getByTestId('m-storage-category-files');
|
|
// The testid wraps only the select row — assert name/badge/description through the field around it.
|
|
expect(screen.getByText('Trip documents')).toBeInTheDocument();
|
|
expect(screen.getByText('files')).toBeInTheDocument();
|
|
expect(screen.getByText(/tickets, PDFs, booking confirmations/)).toBeInTheDocument();
|
|
expect(within(files).getByRole('button')).toBeInTheDocument(); // row still tappable
|
|
expect(screen.queryByTestId('m-storage-category-photos')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-MOB-MSTOR-010: usage renders on rows and the header; never-computed shows the compute prompt', async () => {
|
|
const usage = {
|
|
computedAt: Date.now() - 3_600_000,
|
|
categories: Object.fromEntries(
|
|
(['files', 'journey', 'covers', 'avatars', 'places', 'photos-google', 'photos-trek', 'backups'] as const).map(
|
|
(c) => [c, { objects: 2, bytes: 1024 * 1024 }],
|
|
),
|
|
),
|
|
legacyPhotos: { objects: 0, bytes: 0 },
|
|
};
|
|
usage.categories.files = { objects: 3, bytes: 2048 };
|
|
await renderPanel({ ...baseState(), usage } as StorageAdminState);
|
|
expect(screen.getByText(/Usage computed/)).toBeInTheDocument();
|
|
expect(within(screen.getByTestId('m-storage-backend-backups-local')).getByText(/2 objects · 1\.0 MB/)).toBeInTheDocument();
|
|
// Per-category usage line (the testid wraps only the select row, so assert by text).
|
|
expect(screen.getByText('3 objects · 2.0 KB')).toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-MOB-MSTOR-011: Sync now → running → done via the test poll', async () => {
|
|
let polls = 0;
|
|
await renderPanel(mirroredState());
|
|
server.use(
|
|
http.post('/api/admin/storage/backends/mirror/backfill', () => HttpResponse.json({ started: true })),
|
|
http.get('/api/admin/storage', () => {
|
|
polls += 1;
|
|
const state = mirroredState();
|
|
(state as StorageAdminState).backfills =
|
|
polls < 3
|
|
? [{ backend: 'mirror', status: 'running', done: 1, total: 4, copied: 1, skipped: 0, failed: 0, deleted: 0, startedAt: 1 }]
|
|
: [{ backend: 'mirror', status: 'done', done: 4, total: 4, copied: 4, skipped: 0, failed: 0, deleted: 2, startedAt: 1, finishedAt: 2 }];
|
|
return HttpResponse.json(state);
|
|
}),
|
|
);
|
|
const row = screen.getByTestId('m-storage-backend-backups-local');
|
|
fireEvent.click(within(row).getByRole('button', { name: 'Sync now' }));
|
|
await within(row).findByText(/Syncing… 1\/4/);
|
|
await within(row).findByText(/Sync finished: 4 copied, 2 deleted, 0 failed/);
|
|
});
|
|
|
|
it('FE-MOB-MSTOR-012: a save that added targets raises the sync prompt', async () => {
|
|
await renderPanel();
|
|
server.use(http.put('/api/admin/storage', async () => HttpResponse.json(mirroredState())));
|
|
fireEvent.click(within(screen.getByTestId('m-storage-backend-backups-local')).getByRole('button', { name: 'Edit' }));
|
|
fireEvent.click(screen.getByRole('switch', { name: 'off-box' }));
|
|
fireEvent.click(screen.getByRole('button', { name: 'Apply' }));
|
|
fireEvent.click(screen.getByRole('button', { name: 'Save changes' }));
|
|
await screen.findByText(/Existing objects are not replicated yet/);
|
|
});
|
|
|
|
it('FE-MOB-MSTOR-013: reassigning a populated category prompts before saving; Move strips it from the PUT and POSTs the migration after save', async () => {
|
|
let putBody: unknown;
|
|
let putCalled = false;
|
|
let migrationBody: unknown;
|
|
const usage = {
|
|
computedAt: Date.now(),
|
|
categories: { files: { objects: 3, bytes: 3072 } },
|
|
legacyPhotos: { objects: 0, bytes: 0 },
|
|
};
|
|
await renderPanel({ ...baseState(), usage } as StorageAdminState);
|
|
server.use(
|
|
http.put('/api/admin/storage', async ({ request }) => {
|
|
putCalled = true;
|
|
putBody = await request.json();
|
|
const saved = baseState();
|
|
saved.categories.files = { backend: 'off-box', source: 'settings' };
|
|
return HttpResponse.json(saved);
|
|
}),
|
|
http.post('/api/admin/storage/migrations', async ({ request }) => {
|
|
migrationBody = await request.json();
|
|
return HttpResponse.json({ started: true });
|
|
}),
|
|
);
|
|
fireEvent.click(within(screen.getByTestId('m-storage-category-files')).getByRole('button'));
|
|
fireEvent.click(screen.getByRole('button', { name: 'off-box' }));
|
|
fireEvent.click(screen.getByRole('button', { name: 'Save changes' }));
|
|
|
|
expect(await screen.findByRole('alertdialog')).toBeInTheDocument();
|
|
expect(putCalled).toBe(false);
|
|
expect(screen.getByText(/Trip documents: 3 objects \(3\.0 KB\) from uploads-local to off-box/)).toBeInTheDocument();
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'Move existing objects' }));
|
|
await screen.findByText('Storage configuration saved');
|
|
// files is default-sourced (uploads-local) in baseState() — stripping restores "no override".
|
|
expect((putBody as StorageConfig).categories.files).toBeUndefined();
|
|
expect(migrationBody).toEqual({ category: 'files', to: 'off-box' });
|
|
expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-MOB-MSTOR-014: Just route new writes saves the reassignment as-is, no migration POST', async () => {
|
|
let putBody: unknown;
|
|
let migrationPosted = false;
|
|
const usage = {
|
|
computedAt: Date.now(),
|
|
categories: { files: { objects: 3, bytes: 3072 } },
|
|
legacyPhotos: { objects: 0, bytes: 0 },
|
|
};
|
|
await renderPanel({ ...baseState(), usage } as StorageAdminState);
|
|
server.use(
|
|
http.put('/api/admin/storage', async ({ request }) => {
|
|
putBody = await request.json();
|
|
const saved = baseState();
|
|
saved.categories.files = { backend: 'off-box', source: 'settings' };
|
|
return HttpResponse.json(saved);
|
|
}),
|
|
http.post('/api/admin/storage/migrations', () => {
|
|
migrationPosted = true;
|
|
return HttpResponse.json({ started: true });
|
|
}),
|
|
);
|
|
fireEvent.click(within(screen.getByTestId('m-storage-category-files')).getByRole('button'));
|
|
fireEvent.click(screen.getByRole('button', { name: 'off-box' }));
|
|
fireEvent.click(screen.getByRole('button', { name: 'Save changes' }));
|
|
await screen.findByRole('alertdialog');
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'Just route new writes' }));
|
|
await screen.findByText('Storage configuration saved');
|
|
expect((putBody as StorageConfig).categories.files).toBe('off-box');
|
|
expect(migrationPosted).toBe(false);
|
|
expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('FE-MOB-MSTOR-015: two candidates queue sequentially: the second POSTs only after the first turns terminal', async () => {
|
|
const usage = {
|
|
computedAt: Date.now(),
|
|
categories: {
|
|
files: { objects: 3, bytes: 3072 },
|
|
journey: { objects: 2, bytes: 2048 },
|
|
},
|
|
legacyPhotos: { objects: 0, bytes: 0 },
|
|
};
|
|
await renderPanel({ ...baseState(), usage } as StorageAdminState);
|
|
const savedState = baseState();
|
|
savedState.categories.files = { backend: 'off-box', source: 'settings' };
|
|
savedState.categories.journey = { backend: 'off-box', source: 'settings' };
|
|
const migrationPosts: Array<{ category: StorageCategory; to: string }> = [];
|
|
// Held 'running' until the test explicitly flips this — deterministic,
|
|
// unlike counting poll ticks against the 50ms test interval.
|
|
let firstDone = false;
|
|
let postCount = 0;
|
|
server.use(
|
|
http.put('/api/admin/storage', () => HttpResponse.json(savedState)),
|
|
http.post('/api/admin/storage/migrations', async ({ request }) => {
|
|
// Parse BEFORE counting, so postCount and migrationPosts move together.
|
|
// With the increment first, the await left a window where postCount was
|
|
// already 1 while migrationPosts was still empty — and the GET handler
|
|
// below reports `migrations: []` for an empty migrationPosts, i.e. "no
|
|
// migration running". A poll landing in that window told the queue it was
|
|
// free to dequeue the second candidate, and the `waitFor(postCount === 1)`
|
|
// further down could resolve inside it too. Rare locally, reliable under
|
|
// CI load, where the microtask gap is wider than the 50ms test poll.
|
|
const body = (await request.json()) as { category: StorageCategory; to: string };
|
|
postCount += 1;
|
|
migrationPosts.push(body);
|
|
// Pins the single-flight fix: the second POST must never arrive
|
|
// before the first migration is server-confirmed terminal — a
|
|
// regression here means the queue's synchronous re-fire raced ahead
|
|
// of admin.state showing the first as done.
|
|
if (postCount === 2) expect(firstDone).toBe(true);
|
|
return HttpResponse.json({ started: true });
|
|
}),
|
|
http.get('/api/admin/storage', () => {
|
|
const state = { ...savedState };
|
|
state.migrations =
|
|
migrationPosts.length === 0
|
|
? []
|
|
: [
|
|
{
|
|
category: migrationPosts[0]!.category, from: 'uploads-local', to: migrationPosts[0]!.to,
|
|
status: firstDone ? 'done' : 'running', done: firstDone ? 3 : 1, total: 3,
|
|
copied: firstDone ? 3 : 1, skipped: 0, failed: 0, startedAt: 1,
|
|
...(firstDone ? { finishedAt: 2 } : {}),
|
|
},
|
|
];
|
|
return HttpResponse.json(state);
|
|
}),
|
|
);
|
|
fireEvent.click(within(screen.getByTestId('m-storage-category-files')).getByRole('button'));
|
|
fireEvent.click(within(screen.getByRole('dialog', { name: 'Trip documents' })).getByRole('button', { name: 'off-box' }));
|
|
fireEvent.click(within(screen.getByTestId('m-storage-category-journey')).getByRole('button'));
|
|
fireEvent.click(within(screen.getByRole('dialog', { name: 'Journey photos' })).getByRole('button', { name: 'off-box' }));
|
|
fireEvent.click(screen.getByRole('button', { name: 'Save changes' }));
|
|
await screen.findByRole('alertdialog');
|
|
fireEvent.click(screen.getByRole('button', { name: 'Move existing objects' }));
|
|
await screen.findByText('Storage configuration saved');
|
|
|
|
await waitFor(() => expect(postCount).toBe(1));
|
|
expect(migrationPosts[0]!.category).toBe('files');
|
|
// At the moment the first POST arrives, exactly one POST has happened —
|
|
// and the second must not fire while the first is still running, no
|
|
// matter how many poll ticks elapse.
|
|
expect(postCount).toBe(1);
|
|
await new Promise((r) => setTimeout(r, 200));
|
|
expect(postCount).toBe(1);
|
|
// Once the first turns terminal, the second is dequeued — the POST
|
|
// handler's own assertion (above) pins that it never arrives early.
|
|
firstDone = true;
|
|
await waitFor(() => expect(postCount).toBe(2), { timeout: 2000 });
|
|
expect(migrationPosts[1]!.category).toBe('journey');
|
|
});
|
|
|
|
it('FE-MOB-MSTOR-016: a done migration row shows the reclaimable line; a running one shows progress + cancel wired to DELETE', async () => {
|
|
let cancelledCategory: string | null = null;
|
|
const state = baseState();
|
|
(state as StorageAdminState).migrations = [
|
|
{
|
|
category: 'files', from: 'uploads-local', to: 'off-box',
|
|
status: 'running', done: 2, total: 5, copied: 1, skipped: 1, failed: 0, startedAt: 1,
|
|
},
|
|
{
|
|
category: 'journey', from: 'uploads-local', to: 'off-box',
|
|
status: 'done', done: 4, total: 4, copied: 4, skipped: 0, failed: 0, startedAt: 1, finishedAt: 2,
|
|
reclaimable: { objects: 4, bytes: 4096 },
|
|
},
|
|
];
|
|
await renderPanel(state);
|
|
server.use(
|
|
http.delete('/api/admin/storage/migrations/files', () => {
|
|
cancelledCategory = 'files';
|
|
return HttpResponse.json({ cancelled: true });
|
|
}),
|
|
);
|
|
expect(screen.getByText(/Moving Trip documents… 2\/5/)).toBeInTheDocument();
|
|
expect(screen.getByText(/Move finished: 4 copied, 0 skipped/)).toBeInTheDocument();
|
|
expect(screen.getByText(/4 objects \(4\.0 KB\) remain on uploads-local — reclaim manually/)).toBeInTheDocument();
|
|
fireEvent.click(screen.getByRole('button', { name: 'Cancel move' }));
|
|
await waitFor(() => expect(cancelledCategory).toBe('files'));
|
|
});
|
|
|
|
it('FE-MOB-MSTOR-017: a failed save disarms the sync-prompt snapshot', async () => {
|
|
await renderPanel();
|
|
server.use(http.put('/api/admin/storage', () => HttpResponse.json({ error: 'nope' }, { status: 400 })));
|
|
fireEvent.click(within(screen.getByTestId('m-storage-backend-backups-local')).getByRole('button', { name: 'Edit' }));
|
|
fireEvent.click(screen.getByRole('switch', { name: 'off-box' }));
|
|
fireEvent.click(screen.getByRole('button', { name: 'Apply' }));
|
|
fireEvent.click(screen.getByRole('button', { name: 'Save changes' }));
|
|
await screen.findByText('nope');
|
|
|
|
// The draft-only mirror (never saved) still renders its "Sync now" —
|
|
// clicking it drives a genuine admin.state refresh (via refreshState())
|
|
// that lands a world whose mirror targets grew. With the pending-prompt
|
|
// snapshot disarmed by the failed save, the sync prompt must never appear.
|
|
server.use(
|
|
http.post('/api/admin/storage/backends/backups-local-mirror/backfill', () => HttpResponse.json({ started: true })),
|
|
http.get('/api/admin/storage', () => HttpResponse.json(mirroredState())),
|
|
);
|
|
fireEvent.click(within(screen.getByTestId('m-storage-backend-backups-local')).getByRole('button', { name: 'Sync now' }));
|
|
await new Promise((r) => setTimeout(r, 100));
|
|
expect(screen.queryByText(/Existing objects are not replicated yet/)).not.toBeInTheDocument();
|
|
});
|
|
});
|