feat(web): saveImage routes through transferStore
This commit is contained in:
@@ -8,58 +8,95 @@ vi.mock('../stores/uiStore', () => ({
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const mockStartDownload = vi.fn();
|
||||||
|
const mockGet = vi.fn();
|
||||||
|
|
||||||
|
vi.mock('../stores/transferStore', () => ({
|
||||||
|
useTransferStore: {
|
||||||
|
getState: vi.fn(() => ({
|
||||||
|
startDownload: mockStartDownload,
|
||||||
|
get: mockGet,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
import { saveImage, copyImageToClipboard } from './imageActions';
|
import { saveImage, copyImageToClipboard } from './imageActions';
|
||||||
import { useUIStore } from '../stores/uiStore';
|
import { useUIStore } from '../stores/uiStore';
|
||||||
|
|
||||||
describe('saveImage', () => {
|
describe('saveImage', () => {
|
||||||
let createElementSpy: ReturnType<typeof vi.spyOn>;
|
|
||||||
let mockAnchor: { href: string; download: string; click: ReturnType<typeof vi.fn>; remove: ReturnType<typeof vi.fn> };
|
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mockAnchor = { href: '', download: '', click: vi.fn(), remove: vi.fn() };
|
mockStartDownload.mockReset();
|
||||||
createElementSpy = vi.spyOn(document, 'createElement').mockReturnValue(mockAnchor as any);
|
mockGet.mockReset();
|
||||||
vi.spyOn(document.body, 'appendChild').mockImplementation((node) => node);
|
|
||||||
vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:mock-url');
|
|
||||||
vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('fetches image as blob and triggers download for same-origin URLs', async () => {
|
it('routes downloads through transferStore.startDownload with derived filename', async () => {
|
||||||
const mockBlob = new Blob(['img'], { type: 'image/png' });
|
mockStartDownload.mockResolvedValue('transfer-id-1');
|
||||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(mockBlob));
|
mockGet.mockReturnValue({ id: 'transfer-id-1', state: 'completed' });
|
||||||
|
|
||||||
await saveImage('/api/uploads/abc123_photo.png');
|
await saveImage('/api/uploads/abc123_photo.png');
|
||||||
|
|
||||||
expect(fetch).toHaveBeenCalledWith('/api/uploads/abc123_photo.png');
|
expect(mockStartDownload).toHaveBeenCalledWith('/api/uploads/abc123_photo.png', {
|
||||||
expect(URL.createObjectURL).toHaveBeenCalledWith(mockBlob);
|
filename: 'abc123_photo.png',
|
||||||
expect(mockAnchor.download).toBe('abc123_photo.png');
|
mimetype: 'image/*',
|
||||||
expect(mockAnchor.click).toHaveBeenCalled();
|
tray: true,
|
||||||
expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:mock-url');
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('uses provided filename when given', async () => {
|
it('uses provided filename when given', async () => {
|
||||||
const mockBlob = new Blob(['img'], { type: 'image/png' });
|
mockStartDownload.mockResolvedValue('transfer-id-2');
|
||||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(mockBlob));
|
mockGet.mockReturnValue({ id: 'transfer-id-2', state: 'completed' });
|
||||||
|
|
||||||
await saveImage('/api/uploads/abc123.png', 'my-photo.png');
|
await saveImage('/api/uploads/abc123.png', 'my-photo.png');
|
||||||
|
|
||||||
expect(mockAnchor.download).toBe('my-photo.png');
|
expect(mockStartDownload).toHaveBeenCalledWith('/api/uploads/abc123.png', {
|
||||||
|
filename: 'my-photo.png',
|
||||||
|
mimetype: 'image/*',
|
||||||
|
tray: true,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('falls back to window.open on CORS/fetch error and shows toast', async () => {
|
it('falls back to window.open and toast when startDownload throws', async () => {
|
||||||
vi.spyOn(globalThis, 'fetch').mockRejectedValue(new TypeError('Failed to fetch'));
|
mockStartDownload.mockRejectedValue(new TypeError('Failed to fetch'));
|
||||||
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null);
|
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null);
|
||||||
const mockAddToast = vi.fn();
|
const mockAddToast = vi.fn();
|
||||||
vi.mocked(useUIStore.getState).mockReturnValue({ addToast: mockAddToast } as any);
|
vi.mocked(useUIStore.getState).mockReturnValue({ addToast: mockAddToast } as ReturnType<typeof useUIStore.getState>);
|
||||||
|
|
||||||
await saveImage('https://media.tenor.com/abc/tenor.gif');
|
await saveImage('https://media.tenor.com/abc/tenor.gif');
|
||||||
|
|
||||||
expect(openSpy).toHaveBeenCalledWith('https://media.tenor.com/abc/tenor.gif', '_blank', 'noopener');
|
expect(openSpy).toHaveBeenCalledWith('https://media.tenor.com/abc/tenor.gif', '_blank', 'noopener');
|
||||||
expect(mockAddToast).toHaveBeenCalledWith('Opened in new tab', 'info', 3000);
|
expect(mockAddToast).toHaveBeenCalledWith('Opened in new tab', 'info', 3000);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('falls back to window.open when transfer ends in failed state', async () => {
|
||||||
|
mockStartDownload.mockResolvedValue('transfer-id-3');
|
||||||
|
mockGet.mockReturnValue({
|
||||||
|
id: 'transfer-id-3',
|
||||||
|
state: 'failed',
|
||||||
|
error: { message: 'HTTP 404', permanent: true },
|
||||||
|
});
|
||||||
|
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null);
|
||||||
|
const mockAddToast = vi.fn();
|
||||||
|
vi.mocked(useUIStore.getState).mockReturnValue({ addToast: mockAddToast } as ReturnType<typeof useUIStore.getState>);
|
||||||
|
|
||||||
|
await saveImage('https://external.com/img.png');
|
||||||
|
|
||||||
|
expect(openSpy).toHaveBeenCalledWith('https://external.com/img.png', '_blank', 'noopener');
|
||||||
|
expect(mockAddToast).toHaveBeenCalledWith('Opened in new tab', 'info', 3000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stays silent when user cancels (state aborted)', async () => {
|
||||||
|
mockStartDownload.mockResolvedValue('transfer-id-4');
|
||||||
|
mockGet.mockReturnValue({ id: 'transfer-id-4', state: 'aborted' });
|
||||||
|
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null);
|
||||||
|
|
||||||
|
await saveImage('/api/uploads/cancelled.png');
|
||||||
|
|
||||||
|
expect(openSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('copyImageToClipboard', () => {
|
describe('copyImageToClipboard', () => {
|
||||||
@@ -89,7 +126,7 @@ describe('copyImageToClipboard', () => {
|
|||||||
clipboard: { write: vi.fn(), writeText: mockWriteText },
|
clipboard: { write: vi.fn(), writeText: mockWriteText },
|
||||||
});
|
});
|
||||||
const mockAddToast = vi.fn();
|
const mockAddToast = vi.fn();
|
||||||
vi.mocked(useUIStore.getState).mockReturnValue({ addToast: mockAddToast } as any);
|
vi.mocked(useUIStore.getState).mockReturnValue({ addToast: mockAddToast } as ReturnType<typeof useUIStore.getState>);
|
||||||
|
|
||||||
await copyImageToClipboard('https://media.tenor.com/abc/tenor.gif');
|
await copyImageToClipboard('https://media.tenor.com/abc/tenor.gif');
|
||||||
|
|
||||||
@@ -104,7 +141,7 @@ describe('copyImageToClipboard', () => {
|
|||||||
clipboard: { write: vi.fn(), writeText: mockWriteText },
|
clipboard: { write: vi.fn(), writeText: mockWriteText },
|
||||||
});
|
});
|
||||||
const mockAddToast = vi.fn();
|
const mockAddToast = vi.fn();
|
||||||
vi.mocked(useUIStore.getState).mockReturnValue({ addToast: mockAddToast } as any);
|
vi.mocked(useUIStore.getState).mockReturnValue({ addToast: mockAddToast } as ReturnType<typeof useUIStore.getState>);
|
||||||
|
|
||||||
await copyImageToClipboard('https://external.com/image.jpg');
|
await copyImageToClipboard('https://external.com/image.jpg');
|
||||||
|
|
||||||
|
|||||||
@@ -1,24 +1,35 @@
|
|||||||
import { useUIStore } from '../stores/uiStore';
|
import { useUIStore } from '../stores/uiStore';
|
||||||
|
import { useTransferStore } from '../stores/transferStore';
|
||||||
|
|
||||||
|
function deriveFilename(url: string): string {
|
||||||
|
try {
|
||||||
|
const u = new URL(url, window.location.origin);
|
||||||
|
const last = u.pathname.split('/').pop() || 'image';
|
||||||
|
return last.split('?')[0] || 'image';
|
||||||
|
} catch {
|
||||||
|
return url.split('/').pop()?.split('?')[0] ?? 'image';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Downloads an image by fetching it as a blob and triggering a download.
|
* Downloads an image via the transfer manager. Falls back to opening in a new
|
||||||
* Falls back to opening in a new tab if CORS blocks the fetch.
|
* tab if the transfer pipeline can't fetch the URL (e.g., CORS).
|
||||||
*/
|
*/
|
||||||
export async function saveImage(url: string, filename?: string): Promise<void> {
|
export async function saveImage(url: string, filename?: string): Promise<void> {
|
||||||
const derivedFilename = filename ?? url.split('/').pop()?.split('?')[0] ?? 'image';
|
const fname = filename ?? deriveFilename(url);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(url);
|
const transferId = await useTransferStore.getState().startDownload(url, {
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
filename: fname,
|
||||||
const blob = await res.blob();
|
mimetype: 'image/*',
|
||||||
const blobUrl = URL.createObjectURL(blob);
|
tray: true,
|
||||||
const a = document.createElement('a');
|
});
|
||||||
a.href = blobUrl;
|
// startDownload never throws on user-cancel — it sets state to 'aborted'
|
||||||
a.download = derivedFilename;
|
// and resolves with the id. Detect that and stay silent.
|
||||||
document.body.appendChild(a);
|
const t = useTransferStore.getState().get(transferId);
|
||||||
a.click();
|
if (t?.state === 'failed') {
|
||||||
a.remove();
|
throw new Error(t.error?.message ?? 'Download failed');
|
||||||
URL.revokeObjectURL(blobUrl);
|
}
|
||||||
} catch {
|
} catch {
|
||||||
window.open(url, '_blank', 'noopener');
|
window.open(url, '_blank', 'noopener');
|
||||||
useUIStore.getState().addToast('Opened in new tab', 'info', 3000);
|
useUIStore.getState().addToast('Opened in new tab', 'info', 3000);
|
||||||
|
|||||||
Reference in New Issue
Block a user