mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-08-14 09:43:22 -03:00
feat(delete): add undo toasts and harden delete modals
This commit is contained in:
@@ -0,0 +1,283 @@
|
||||
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
|
||||
|
||||
const {
|
||||
MODAL_UTILS_MODULE,
|
||||
MODAL_MANAGER_MODULE,
|
||||
API_FACTORY_MODULE,
|
||||
UI_HELPERS_MODULE,
|
||||
I18N_MODULE,
|
||||
UNDO_HELPERS_MODULE,
|
||||
STATE_MODULE,
|
||||
} = vi.hoisted(() => ({
|
||||
MODAL_UTILS_MODULE: new URL('../../../static/js/utils/modalUtils.js', import.meta.url).pathname,
|
||||
MODAL_MANAGER_MODULE: new URL('../../../static/js/managers/ModalManager.js', import.meta.url).pathname,
|
||||
API_FACTORY_MODULE: new URL('../../../static/js/api/modelApiFactory.js', import.meta.url).pathname,
|
||||
UI_HELPERS_MODULE: new URL('../../../static/js/utils/uiHelpers.js', import.meta.url).pathname,
|
||||
I18N_MODULE: new URL('../../../static/js/utils/i18nHelpers.js', import.meta.url).pathname,
|
||||
UNDO_HELPERS_MODULE: new URL('../../../static/js/utils/undoHelpers.js', import.meta.url).pathname,
|
||||
STATE_MODULE: new URL('../../../static/js/state/index.js', import.meta.url).pathname,
|
||||
}));
|
||||
|
||||
const deleteModelMock = vi.fn();
|
||||
const resetAndReloadMock = vi.fn();
|
||||
const showActionToastMock = vi.fn();
|
||||
const handleUndoDeleteMock = vi.fn();
|
||||
const translateMock = vi.fn((key) => key);
|
||||
const closeModalMock = vi.fn();
|
||||
const showModalMock = vi.fn();
|
||||
|
||||
vi.mock(MODAL_MANAGER_MODULE, () => ({
|
||||
modalManager: {
|
||||
getModal: vi.fn((id) => ({ element: document.getElementById(id) })),
|
||||
showModal: showModalMock,
|
||||
closeModal: closeModalMock,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock(API_FACTORY_MODULE, () => ({
|
||||
getModelApiClient: vi.fn(() => ({ deleteModel: deleteModelMock })),
|
||||
resetAndReload: resetAndReloadMock,
|
||||
}));
|
||||
|
||||
vi.mock(UI_HELPERS_MODULE, () => ({
|
||||
showActionToast: showActionToastMock,
|
||||
}));
|
||||
|
||||
vi.mock(I18N_MODULE, () => ({
|
||||
translate: translateMock,
|
||||
}));
|
||||
|
||||
vi.mock(UNDO_HELPERS_MODULE, () => ({
|
||||
handleUndoDelete: handleUndoDeleteMock,
|
||||
}));
|
||||
|
||||
describe('modalUtils confirmDelete undo flow', () => {
|
||||
beforeEach(() => {
|
||||
deleteModelMock.mockReset();
|
||||
resetAndReloadMock.mockReset();
|
||||
showActionToastMock.mockReset();
|
||||
handleUndoDeleteMock.mockReset();
|
||||
translateMock.mockClear();
|
||||
closeModalMock.mockReset();
|
||||
showModalMock.mockReset();
|
||||
document.body.innerHTML = `
|
||||
<div class="model-card" data-filepath="/models/foo.safetensors" data-name="Foo Model"></div>
|
||||
<div id="deleteModal"><div class="delete-model-info"></div></div>
|
||||
`;
|
||||
window.modelDuplicatesManager = undefined;
|
||||
});
|
||||
|
||||
it('shows the undo action toast and wires undo to handleUndoDelete + resetAndReload', async () => {
|
||||
deleteModelMock.mockResolvedValue({ success: true, batch_id: 'batch-9' });
|
||||
|
||||
const { showDeleteModal, confirmDelete } = await import(MODAL_UTILS_MODULE);
|
||||
|
||||
showDeleteModal('/models/foo.safetensors');
|
||||
await confirmDelete();
|
||||
|
||||
expect(deleteModelMock).toHaveBeenCalledWith('/models/foo.safetensors');
|
||||
expect(closeModalMock).toHaveBeenCalledWith('deleteModal');
|
||||
expect(showActionToastMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
const [key, params, type, options] = showActionToastMock.mock.calls[0];
|
||||
expect(key).toBe('toast.undo.deleted');
|
||||
expect(params).toEqual({ name: 'Foo Model' });
|
||||
expect(type).toBe('success');
|
||||
expect(options.actionText).toBe('toast.undo.action');
|
||||
expect(translateMock).toHaveBeenCalledWith('toast.undo.action');
|
||||
|
||||
// Clicking Undo posts the batch and refreshes the model list
|
||||
options.onAction();
|
||||
expect(handleUndoDeleteMock).toHaveBeenCalledTimes(1);
|
||||
const [batchId, refreshFn] = handleUndoDeleteMock.mock.calls[0];
|
||||
expect(batchId).toBe('batch-9');
|
||||
expect(typeof refreshFn).toBe('function');
|
||||
|
||||
refreshFn();
|
||||
expect(resetAndReloadMock).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it('does not show the action toast when the delete was not staged', async () => {
|
||||
deleteModelMock.mockResolvedValue({ success: true, batch_id: null });
|
||||
|
||||
const { showDeleteModal, confirmDelete } = await import(MODAL_UTILS_MODULE);
|
||||
|
||||
showDeleteModal('/models/foo.safetensors');
|
||||
await confirmDelete();
|
||||
|
||||
expect(showActionToastMock).not.toHaveBeenCalled();
|
||||
expect(closeModalMock).toHaveBeenCalledWith('deleteModal');
|
||||
});
|
||||
|
||||
it('falls back to the file name when no card is present', async () => {
|
||||
deleteModelMock.mockResolvedValue({ success: true, batch_id: 'batch-10' });
|
||||
document.querySelector('.model-card').remove();
|
||||
|
||||
const { showDeleteModal, confirmDelete } = await import(MODAL_UTILS_MODULE);
|
||||
|
||||
showDeleteModal('/models/bar.safetensors');
|
||||
await confirmDelete();
|
||||
|
||||
expect(showActionToastMock).toHaveBeenCalledTimes(1);
|
||||
expect(showActionToastMock.mock.calls[0][1]).toEqual({ name: 'bar.safetensors' });
|
||||
});
|
||||
|
||||
it('refreshes the duplicates badge when the manager is available', async () => {
|
||||
deleteModelMock.mockResolvedValue({ success: true, batch_id: 'batch-11' });
|
||||
const updateBadge = vi.fn();
|
||||
window.modelDuplicatesManager = { updateDuplicatesBadgeAfterRefresh: updateBadge };
|
||||
|
||||
const { showDeleteModal, confirmDelete } = await import(MODAL_UTILS_MODULE);
|
||||
|
||||
showDeleteModal('/models/foo.safetensors');
|
||||
await confirmDelete();
|
||||
|
||||
expect(updateBadge).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('modalUtils armDeleteButton delay-activate', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
deleteModelMock.mockReset();
|
||||
showModalMock.mockReset();
|
||||
closeModalMock.mockReset();
|
||||
document.body.innerHTML = `
|
||||
<div class="model-card" data-filepath="/models/foo.safetensors" data-name="Foo Model"></div>
|
||||
<div id="deleteModal">
|
||||
<div class="delete-model-info"></div>
|
||||
<button class="cancel-btn">Cancel</button>
|
||||
<button class="delete-btn">Delete</button>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('opens with the delete button disabled and enables it after exactly 1500ms', async () => {
|
||||
const { showDeleteModal } = await import(MODAL_UTILS_MODULE);
|
||||
|
||||
showDeleteModal('/models/foo.safetensors');
|
||||
|
||||
const deleteBtn = document.querySelector('#deleteModal .delete-btn');
|
||||
expect(deleteBtn.disabled).toBe(true);
|
||||
|
||||
vi.advanceTimersByTime(1499);
|
||||
expect(deleteBtn.disabled).toBe(true);
|
||||
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(deleteBtn.disabled).toBe(false);
|
||||
});
|
||||
|
||||
it('clicking the disabled delete button fires nothing', async () => {
|
||||
const { showDeleteModal } = await import(MODAL_UTILS_MODULE);
|
||||
|
||||
showDeleteModal('/models/foo.safetensors');
|
||||
|
||||
const deleteBtn = document.querySelector('#deleteModal .delete-btn');
|
||||
deleteBtn.click();
|
||||
|
||||
expect(deleteBtn.disabled).toBe(true);
|
||||
expect(deleteModelMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('closing during the countdown clears the timer and reopening re-arms a full 1500ms', async () => {
|
||||
const { showDeleteModal, closeDeleteModal } = await import(MODAL_UTILS_MODULE);
|
||||
|
||||
showDeleteModal('/models/foo.safetensors');
|
||||
const deleteBtn = document.querySelector('#deleteModal .delete-btn');
|
||||
|
||||
vi.advanceTimersByTime(1400);
|
||||
closeDeleteModal();
|
||||
expect(closeModalMock).toHaveBeenCalledWith('deleteModal');
|
||||
|
||||
// Reopen — the stale timer must not enable the button early
|
||||
showDeleteModal('/models/foo.safetensors');
|
||||
expect(deleteBtn.disabled).toBe(true);
|
||||
|
||||
vi.advanceTimersByTime(1499);
|
||||
expect(deleteBtn.disabled).toBe(true);
|
||||
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(deleteBtn.disabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('modalUtils showDeleteModal warning copy and size line', () => {
|
||||
beforeEach(() => {
|
||||
showModalMock.mockReset();
|
||||
closeModalMock.mockReset();
|
||||
translateMock.mockClear();
|
||||
translateMock.mockImplementation((key) => key);
|
||||
document.body.innerHTML = `
|
||||
<div class="model-card" data-filepath="/models/foo.safetensors" data-name="Foo Model" data-file_size="2147483648"></div>
|
||||
<div id="deleteModal">
|
||||
<div class="delete-model-info"></div>
|
||||
<button class="delete-btn">Delete</button>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
const { state } = await import(STATE_MODULE);
|
||||
state.global.settings.delete_undo_enabled = true;
|
||||
});
|
||||
|
||||
function modelInfoHtml() {
|
||||
return document.querySelector('#deleteModal .delete-model-info').innerHTML;
|
||||
}
|
||||
|
||||
it('shows the recoverable warning when delete_undo_enabled is truthy', async () => {
|
||||
const { state } = await import(STATE_MODULE);
|
||||
state.global.settings.delete_undo_enabled = true;
|
||||
|
||||
const { showDeleteModal } = await import(MODAL_UTILS_MODULE);
|
||||
showDeleteModal('/models/foo.safetensors');
|
||||
|
||||
expect(modelInfoHtml()).toContain('modals.deleteModel.recoverableWarning');
|
||||
expect(modelInfoHtml()).not.toContain('modals.deleteModel.permanentWarning');
|
||||
});
|
||||
|
||||
it('shows the permanent warning when delete_undo_enabled is falsy', async () => {
|
||||
const { state } = await import(STATE_MODULE);
|
||||
state.global.settings.delete_undo_enabled = false;
|
||||
|
||||
const { showDeleteModal } = await import(MODAL_UTILS_MODULE);
|
||||
showDeleteModal('/models/foo.safetensors');
|
||||
|
||||
expect(modelInfoHtml()).toContain('modals.deleteModel.permanentWarning');
|
||||
expect(modelInfoHtml()).not.toContain('modals.deleteModel.recoverableWarning');
|
||||
});
|
||||
|
||||
it('falls back to the neutral permanent warning when the setting is unavailable', async () => {
|
||||
const { state } = await import(STATE_MODULE);
|
||||
delete state.global.settings.delete_undo_enabled;
|
||||
|
||||
const { showDeleteModal } = await import(MODAL_UTILS_MODULE);
|
||||
showDeleteModal('/models/foo.safetensors');
|
||||
|
||||
expect(modelInfoHtml()).toContain('modals.deleteModel.permanentWarning');
|
||||
});
|
||||
|
||||
it('appends a formatted "Frees {size}" line when the card carries a file size', async () => {
|
||||
translateMock.mockImplementation((key, params) =>
|
||||
params && params.size ? `${key} ${params.size}` : key
|
||||
);
|
||||
|
||||
const { showDeleteModal } = await import(MODAL_UTILS_MODULE);
|
||||
showDeleteModal('/models/foo.safetensors');
|
||||
|
||||
expect(modelInfoHtml()).toContain('modals.deleteModel.freesSpace 2.0 GB');
|
||||
});
|
||||
|
||||
it('omits the size line when the card has no file size dataset', async () => {
|
||||
document.querySelector('.model-card').removeAttribute('data-file_size');
|
||||
|
||||
const { showDeleteModal } = await import(MODAL_UTILS_MODULE);
|
||||
showDeleteModal('/models/foo.safetensors');
|
||||
|
||||
expect(modelInfoHtml()).not.toContain('modals.deleteModel.freesSpace');
|
||||
});
|
||||
});
|
||||
@@ -110,6 +110,142 @@ describe('UI helper DOM utilities', () => {
|
||||
expect(toast.classList.contains('show')).toBe(false);
|
||||
});
|
||||
|
||||
it('renders an action button and countdown span for action toasts', async () => {
|
||||
vi.useFakeTimers();
|
||||
translateMock.mockReturnValue('Deleted Demo Model');
|
||||
|
||||
const { showActionToast } = await import(UI_HELPERS_MODULE);
|
||||
|
||||
const onAction = vi.fn();
|
||||
showActionToast('toast.undo.deleted', { name: 'Demo Model' }, 'success', {
|
||||
actionText: 'Undo',
|
||||
onAction,
|
||||
});
|
||||
|
||||
const toast = document.querySelector('.toast-container .toast');
|
||||
expect(toast).not.toBeNull();
|
||||
expect(toast.classList.contains('toast-success')).toBe(true);
|
||||
expect(translateMock).toHaveBeenCalledWith('toast.undo.deleted', { name: 'Demo Model' });
|
||||
|
||||
const button = toast.querySelector('.toast-action-btn');
|
||||
expect(button).not.toBeNull();
|
||||
expect(button.textContent).toBe('Undo');
|
||||
|
||||
const countdown = toast.querySelector('.toast-countdown');
|
||||
expect(countdown).not.toBeNull();
|
||||
expect(countdown.textContent).toBe('(30s)');
|
||||
|
||||
// Ticking one second updates the countdown text
|
||||
vi.advanceTimersByTime(1000);
|
||||
expect(countdown.textContent).toBe('(29s)');
|
||||
|
||||
// Drain remaining timers so no state leaks into other tests
|
||||
vi.advanceTimersByTime(30000);
|
||||
});
|
||||
|
||||
it('invokes onAction once and dismisses immediately when the button is clicked', async () => {
|
||||
vi.useFakeTimers();
|
||||
translateMock.mockReturnValue('Deleted Demo Model');
|
||||
|
||||
const { showActionToast } = await import(UI_HELPERS_MODULE);
|
||||
|
||||
const onAction = vi.fn();
|
||||
showActionToast('toast.undo.deleted', {}, 'success', {
|
||||
actionText: 'Undo',
|
||||
onAction,
|
||||
});
|
||||
|
||||
const toast = document.querySelector('.toast-container .toast');
|
||||
toast.querySelector('.toast-action-btn').click();
|
||||
|
||||
expect(onAction).toHaveBeenCalledTimes(1);
|
||||
expect(toast.classList.contains('show')).toBe(false);
|
||||
|
||||
// Dismissal removes the element after the transition ends
|
||||
toast.dispatchEvent(new Event('transitionend', { bubbles: true }));
|
||||
expect(document.querySelector('.toast-container .toast')).toBeNull();
|
||||
expect(document.querySelector('.toast-container')).toBeNull();
|
||||
});
|
||||
|
||||
it('calls onAction exactly once when the button is double-clicked', async () => {
|
||||
vi.useFakeTimers();
|
||||
translateMock.mockReturnValue('Deleted Demo Model');
|
||||
|
||||
const { showActionToast } = await import(UI_HELPERS_MODULE);
|
||||
|
||||
const onAction = vi.fn();
|
||||
showActionToast('toast.undo.deleted', {}, 'success', {
|
||||
actionText: 'Undo',
|
||||
onAction,
|
||||
});
|
||||
|
||||
const button = document.querySelector('.toast-action-btn');
|
||||
button.click();
|
||||
button.click();
|
||||
|
||||
expect(onAction).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('dismisses the toast when the countdown reaches zero', async () => {
|
||||
vi.useFakeTimers();
|
||||
translateMock.mockReturnValue('Deleted Demo Model');
|
||||
// Async RAF mirrors production ordering: the countdown interval is
|
||||
// registered before the dismiss timeout, so the final tick displays (0s)
|
||||
globalThis.requestAnimationFrame = (cb) => setTimeout(cb, 0);
|
||||
|
||||
const { showActionToast } = await import(UI_HELPERS_MODULE);
|
||||
|
||||
showActionToast('toast.undo.deleted', {}, 'success', {
|
||||
actionText: 'Undo',
|
||||
onAction: vi.fn(),
|
||||
durationMs: 3000,
|
||||
});
|
||||
|
||||
vi.advanceTimersByTime(0); // Flush the RAF callback
|
||||
const toast = document.querySelector('.toast-container .toast');
|
||||
const countdown = toast.querySelector('.toast-countdown');
|
||||
expect(countdown.textContent).toBe('(3s)');
|
||||
|
||||
vi.advanceTimersByTime(2000);
|
||||
expect(countdown.textContent).toBe('(1s)');
|
||||
expect(toast.classList.contains('show')).toBe(true);
|
||||
|
||||
vi.advanceTimersByTime(1000);
|
||||
expect(countdown.textContent).toBe('(0s)');
|
||||
expect(toast.classList.contains('show')).toBe(false);
|
||||
|
||||
toast.dispatchEvent(new Event('transitionend', { bubbles: true }));
|
||||
expect(document.querySelector('.toast-container .toast')).toBeNull();
|
||||
});
|
||||
|
||||
it('clears the countdown interval when dismissed via the action button', async () => {
|
||||
vi.useFakeTimers();
|
||||
translateMock.mockReturnValue('Deleted Demo Model');
|
||||
|
||||
const { showActionToast } = await import(UI_HELPERS_MODULE);
|
||||
|
||||
const onAction = vi.fn();
|
||||
showActionToast('toast.undo.deleted', {}, 'success', {
|
||||
actionText: 'Undo',
|
||||
onAction,
|
||||
durationMs: 30000,
|
||||
});
|
||||
|
||||
const toast = document.querySelector('.toast-container .toast');
|
||||
const countdown = toast.querySelector('.toast-countdown');
|
||||
toast.querySelector('.toast-action-btn').click();
|
||||
|
||||
// Advancing past the full duration must not tick the countdown further,
|
||||
// throw, or re-dismiss the already-dismissed toast
|
||||
vi.advanceTimersByTime(60000);
|
||||
expect(countdown.textContent).toBe('(30s)');
|
||||
expect(onAction).toHaveBeenCalledTimes(1);
|
||||
expect(toast.classList.contains('show')).toBe(false);
|
||||
|
||||
toast.dispatchEvent(new Event('transitionend', { bubbles: true }));
|
||||
expect(document.querySelector('.toast-container')).toBeNull();
|
||||
});
|
||||
|
||||
it('toggles the persisted theme and updates DOM attributes', async () => {
|
||||
getStorageItemMock.mockReturnValue('light');
|
||||
document.body.innerHTML = '<button class="theme-toggle"></button>';
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
|
||||
|
||||
const {
|
||||
UI_HELPERS_MODULE,
|
||||
UNDO_HELPERS_MODULE,
|
||||
} = vi.hoisted(() => ({
|
||||
UI_HELPERS_MODULE: new URL('../../../static/js/utils/uiHelpers.js', import.meta.url).pathname,
|
||||
UNDO_HELPERS_MODULE: new URL('../../../static/js/utils/undoHelpers.js', import.meta.url).pathname,
|
||||
}));
|
||||
|
||||
const showToastMock = vi.fn();
|
||||
|
||||
vi.mock(UI_HELPERS_MODULE, () => ({
|
||||
showToast: showToastMock,
|
||||
}));
|
||||
|
||||
describe('handleUndoDelete', () => {
|
||||
beforeEach(() => {
|
||||
showToastMock.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete global.fetch;
|
||||
});
|
||||
|
||||
it('posts the batch id, refreshes once, and shows the restored toast on 200', async () => {
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ success: true, restored: ['/models/foo.safetensors'] }),
|
||||
});
|
||||
|
||||
const { handleUndoDelete } = await import(UNDO_HELPERS_MODULE);
|
||||
|
||||
const refreshFn = vi.fn();
|
||||
const result = await handleUndoDelete('batch-1', refreshFn);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(global.fetch).toHaveBeenCalledWith('/api/lm/undo-delete', expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ batch_id: 'batch-1' }),
|
||||
}));
|
||||
expect(refreshFn).toHaveBeenCalledTimes(1);
|
||||
expect(showToastMock).toHaveBeenCalledTimes(1);
|
||||
expect(showToastMock).toHaveBeenCalledWith('toast.undo.restored', {}, 'success');
|
||||
});
|
||||
|
||||
it('shows the expired toast for a 404 whose error body mentions expired', async () => {
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 404,
|
||||
statusText: 'Not Found',
|
||||
json: async () => ({ success: false, error: 'Undo batch expired and was purged' }),
|
||||
});
|
||||
|
||||
const { handleUndoDelete } = await import(UNDO_HELPERS_MODULE);
|
||||
|
||||
const refreshFn = vi.fn();
|
||||
const result = await handleUndoDelete('batch-gone', refreshFn);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(refreshFn).not.toHaveBeenCalled();
|
||||
expect(showToastMock).toHaveBeenCalledTimes(1);
|
||||
expect(showToastMock).toHaveBeenCalledWith('toast.undo.expired', {}, 'error');
|
||||
});
|
||||
|
||||
it('shows the failed toast with the server message for a 404 occupied path', async () => {
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 404,
|
||||
statusText: 'Not Found',
|
||||
json: async () => ({ success: false, error: 'Target path occupied' }),
|
||||
});
|
||||
|
||||
const { handleUndoDelete } = await import(UNDO_HELPERS_MODULE);
|
||||
|
||||
const result = await handleUndoDelete('batch-occupied', vi.fn());
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(showToastMock).toHaveBeenCalledTimes(1);
|
||||
expect(showToastMock).toHaveBeenCalledWith('toast.undo.failed', { error: 'Target path occupied' }, 'error');
|
||||
});
|
||||
|
||||
it('shows the failed toast when the error body is not parseable', async () => {
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 404,
|
||||
statusText: 'Not Found',
|
||||
json: async () => { throw new Error('invalid json'); },
|
||||
});
|
||||
|
||||
const { handleUndoDelete } = await import(UNDO_HELPERS_MODULE);
|
||||
|
||||
const result = await handleUndoDelete('batch-malformed', vi.fn());
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(showToastMock).toHaveBeenCalledTimes(1);
|
||||
expect(showToastMock).toHaveBeenCalledWith('toast.undo.failed', { error: 'Not Found' }, 'error');
|
||||
});
|
||||
|
||||
it('shows the failed toast on network errors', async () => {
|
||||
global.fetch = vi.fn().mockRejectedValue(new Error('connection reset'));
|
||||
|
||||
const { handleUndoDelete } = await import(UNDO_HELPERS_MODULE);
|
||||
|
||||
const refreshFn = vi.fn();
|
||||
const result = await handleUndoDelete('batch-net', refreshFn);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(refreshFn).not.toHaveBeenCalled();
|
||||
expect(showToastMock).toHaveBeenCalledTimes(1);
|
||||
expect(showToastMock).toHaveBeenCalledWith('toast.undo.failed', { error: 'connection reset' }, 'error');
|
||||
});
|
||||
|
||||
it('suppresses the toast and refresh when the options disable them', async () => {
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ success: true }),
|
||||
});
|
||||
|
||||
const { handleUndoDelete } = await import(UNDO_HELPERS_MODULE);
|
||||
|
||||
const refreshFn = vi.fn();
|
||||
const result = await handleUndoDelete('batch-quiet', refreshFn, { showToast: false, refresh: false });
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(global.fetch).toHaveBeenCalledTimes(1);
|
||||
expect(refreshFn).not.toHaveBeenCalled();
|
||||
expect(showToastMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user