Files
superset2/superset-frontend/src/utils/export.test.ts
2025-10-10 14:58:13 -07:00

279 lines
8.5 KiB
TypeScript

/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { SupersetClient, logging } from '@superset-ui/core';
import contentDisposition from 'content-disposition';
import handleResourceExport from './export';
// Mock dependencies
jest.mock('@superset-ui/core', () => ({
SupersetClient: {
get: jest.fn(),
},
logging: {
warn: jest.fn(),
error: jest.fn(),
},
}));
jest.mock('content-disposition');
jest.mock('./pathUtils', () => ({
ensureAppRoot: jest.fn((path: string) => path),
}));
let mockBlob: Blob;
let mockResponse: Response;
let createElementSpy: jest.SpyInstance;
let createObjectURLSpy: jest.SpyInstance;
let revokeObjectURLSpy: jest.SpyInstance;
beforeEach(() => {
// Reset all mocks
jest.clearAllMocks();
// Mock Blob
mockBlob = new Blob(['test data'], { type: 'application/zip' });
// Mock Response with Headers
mockResponse = {
headers: new Headers({
'Content-Disposition': 'attachment; filename="dashboard_export.zip"',
}),
blob: jest.fn().mockResolvedValue(mockBlob),
} as unknown as Response;
// Mock SupersetClient.get
(SupersetClient.get as jest.Mock).mockResolvedValue(mockResponse);
// Mock DOM APIs
const mockAnchor = document.createElement('a');
mockAnchor.click = jest.fn();
createElementSpy = jest
.spyOn(document, 'createElement')
.mockReturnValue(mockAnchor);
jest.spyOn(document.body, 'appendChild').mockImplementation(() => mockAnchor);
jest.spyOn(document.body, 'removeChild').mockImplementation(() => mockAnchor);
// Mock URL.createObjectURL and revokeObjectURL
createObjectURLSpy = jest
.spyOn(window.URL, 'createObjectURL')
.mockReturnValue('blob:mock-url');
// Create revokeObjectURL if it doesn't exist
if (!window.URL.revokeObjectURL) {
window.URL.revokeObjectURL = jest.fn();
}
revokeObjectURLSpy = jest.spyOn(window.URL, 'revokeObjectURL');
});
afterEach(() => {
createElementSpy.mockRestore();
createObjectURLSpy.mockRestore();
if (revokeObjectURLSpy) {
revokeObjectURLSpy.mockRestore();
}
});
test('exports resource with correct endpoint and headers', async () => {
const doneMock = jest.fn();
await handleResourceExport('dashboard', [1, 2, 3], doneMock);
expect(SupersetClient.get).toHaveBeenCalledWith({
endpoint: '/api/v1/dashboard/export/?q=!(1,2,3)',
headers: {
Accept: 'application/zip, application/x-zip-compressed, text/plain',
},
parseMethod: 'raw',
});
});
test('creates blob and triggers download', async () => {
const doneMock = jest.fn();
await handleResourceExport('dashboard', [1], doneMock);
// Check that blob was created
expect(mockResponse.blob).toHaveBeenCalled();
// Check that object URL was created
expect(window.URL.createObjectURL).toHaveBeenCalledWith(mockBlob);
// Check that anchor element was created and configured
expect(document.createElement).toHaveBeenCalledWith('a');
const anchor = document.createElement('a');
expect(anchor.href).toBe('blob:mock-url');
expect(anchor.download).toBe('dashboard_export.zip');
// eslint-disable-next-line jest-dom/prefer-to-have-style -- toHaveStyle not available without React Testing Library
expect(anchor.style.display).toBe('none');
// Check that click was triggered
expect(anchor.click).toHaveBeenCalled();
// Check cleanup
expect(document.body.removeChild).toHaveBeenCalled();
expect(window.URL.revokeObjectURL).toHaveBeenCalledWith('blob:mock-url');
});
test('calls done callback on success', async () => {
const doneMock = jest.fn();
await handleResourceExport('dashboard', [1], doneMock);
expect(doneMock).toHaveBeenCalled();
});
test('uses default filename when Content-Disposition is missing', async () => {
mockResponse = {
headers: new Headers(),
blob: jest.fn().mockResolvedValue(mockBlob),
} as unknown as Response;
(SupersetClient.get as jest.Mock).mockResolvedValue(mockResponse);
const doneMock = jest.fn();
await handleResourceExport('chart', [42], doneMock);
const anchor = document.createElement('a');
expect(anchor.download).toBe('chart_export.zip');
});
test('handles Content-Disposition parsing errors gracefully', async () => {
(contentDisposition.parse as jest.Mock).mockImplementationOnce(() => {
throw new Error('Invalid header');
});
const doneMock = jest.fn();
await handleResourceExport('dashboard', [1], doneMock);
// Should fall back to default filename
const anchor = document.createElement('a');
expect(anchor.download).toBe('dashboard_export.zip');
expect(doneMock).toHaveBeenCalled();
});
test('handles API errors and calls done callback', async () => {
const apiError = new Error('API Error');
(SupersetClient.get as jest.Mock).mockRejectedValue(apiError);
const doneMock = jest.fn();
await expect(
handleResourceExport('dashboard', [1], doneMock),
).rejects.toThrow('API Error');
expect(doneMock).toHaveBeenCalled();
});
test('handles blob conversion errors', async () => {
const blobError = new Error('Blob conversion failed');
mockResponse.blob = jest.fn().mockRejectedValue(blobError);
(SupersetClient.get as jest.Mock).mockResolvedValue(mockResponse);
const doneMock = jest.fn();
await expect(
handleResourceExport('dashboard', [1], doneMock),
).rejects.toThrow('Blob conversion failed');
expect(doneMock).toHaveBeenCalled();
});
test('exports multiple resources with correct IDs', async () => {
const doneMock = jest.fn();
await handleResourceExport('dataset', [10, 20, 30, 40], doneMock);
expect(SupersetClient.get).toHaveBeenCalledWith(
expect.objectContaining({
endpoint: '/api/v1/dataset/export/?q=!(10,20,30,40)',
}),
);
});
test('parses filename from Content-Disposition with quotes', async () => {
(contentDisposition.parse as jest.Mock).mockReturnValueOnce({
type: 'attachment',
parameters: { filename: 'my_custom_export.zip' },
});
const doneMock = jest.fn();
await handleResourceExport('dashboard', [1], doneMock);
const anchor = document.createElement('a');
expect(anchor.download).toBe('my_custom_export.zip');
});
test('warns when export exceeds maximum blob size', async () => {
const largeFileSize = 150 * 1024 * 1024; // 150MB
mockResponse = {
headers: new Headers({
'Content-Length': largeFileSize.toString(),
'Content-Disposition': 'attachment; filename="large_export.zip"',
}),
blob: jest.fn().mockResolvedValue(mockBlob),
} as unknown as Response;
(SupersetClient.get as jest.Mock).mockResolvedValue(mockResponse);
const doneMock = jest.fn();
await handleResourceExport('dashboard', [1], doneMock);
expect(logging.warn).toHaveBeenCalledWith(
expect.stringContaining('exceeds maximum blob size'),
);
expect(doneMock).toHaveBeenCalled();
});
test('handles various resource types', async () => {
const doneMock = jest.fn();
await handleResourceExport('dashboard', [1], doneMock);
expect(SupersetClient.get).toHaveBeenCalledWith(
expect.objectContaining({
endpoint: '/api/v1/dashboard/export/?q=!(1)',
}),
);
await handleResourceExport('chart', [1], doneMock);
expect(SupersetClient.get).toHaveBeenCalledWith(
expect.objectContaining({
endpoint: '/api/v1/chart/export/?q=!(1)',
}),
);
await handleResourceExport('dataset', [1], doneMock);
expect(SupersetClient.get).toHaveBeenCalledWith(
expect.objectContaining({
endpoint: '/api/v1/dataset/export/?q=!(1)',
}),
);
await handleResourceExport('database', [1], doneMock);
expect(SupersetClient.get).toHaveBeenCalledWith(
expect.objectContaining({
endpoint: '/api/v1/database/export/?q=!(1)',
}),
);
await handleResourceExport('query', [1], doneMock);
expect(SupersetClient.get).toHaveBeenCalledWith(
expect.objectContaining({
endpoint: '/api/v1/query/export/?q=!(1)',
}),
);
expect(doneMock).toHaveBeenCalledTimes(5);
});