Files
Charon/tests/tasks/backups-create.spec.ts

577 lines
21 KiB
TypeScript

/**
* Backups Page - Creation and List E2E Tests
*
* Tests for backup creation, listing, deletion, and download functionality.
* Covers 17 test scenarios as defined in phase5-implementation.md.
*
* Test Categories:
* - Page Layout (3 tests): heading, create button visibility, role-based access
* - Backup List Display (4 tests): empty state, backup list, sorting, loading
* - Create Backup Flow (5 tests): create success, toast, list refresh, button disable, error handling
* - Delete Backup (3 tests): delete with confirmation, cancel delete, error handling
* - Download Backup (2 tests): download trigger, file handling
*/
import { test, expect, loginUser, TEST_PASSWORD } from '../fixtures/auth-fixtures';
import { setupBackupsList, BackupFile, BACKUP_SELECTORS } from '../utils/phase5-helpers';
import { waitForToast, waitForLoadingComplete, waitForAPIResponse } from '../utils/wait-helpers';
/**
* Mock backup data for testing
*/
const mockBackups: BackupFile[] = [
{ filename: 'backup_2024-01-15_120000.tar.gz', size: 1048576, time: '2024-01-15T12:00:00Z' },
{ filename: 'backup_2024-01-14_120000.tar.gz', size: 2097152, time: '2024-01-14T12:00:00Z' },
];
/**
* Selectors for the Backups page
*/
const SELECTORS = {
pageTitle: 'h1',
createBackupButton: 'button:has-text("Create Backup")',
loadingSkeleton: '[data-testid="loading-skeleton"]',
emptyState: '[data-testid="empty-state"]',
backupTable: '[data-testid="backup-table"]',
backupRow: '[data-testid="backup-row"]',
downloadBtn: '[data-testid="backup-download-btn"]',
restoreBtn: '[data-testid="backup-restore-btn"]',
deleteBtn: '[data-testid="backup-delete-btn"]',
confirmDialog: '[role="dialog"]',
confirmButton: 'button:has-text("Delete")',
cancelButton: 'button:has-text("Cancel")',
};
test.describe('Backups Page - Creation and List', () => {
// =========================================================================
// Page Layout Tests (3 tests)
// =========================================================================
test.describe('Page Layout', () => {
test('should display backups page with correct heading', async ({ page, authenticatedUser }) => {
await loginUser(page, authenticatedUser);
await page.goto('/tasks/backups');
await waitForLoadingComplete(page);
await expect(page.locator(SELECTORS.pageTitle)).toContainText(/backups/i);
});
test('should show Create Backup button for admin users', async ({ page, adminUser }) => {
await loginUser(page, adminUser);
await page.goto('/tasks/backups');
await waitForLoadingComplete(page);
const createButton = page.locator(SELECTORS.createBackupButton).first();
await expect(createButton).toBeVisible();
await expect(createButton).toBeEnabled();
});
test('should hide Create Backup button for guest users', async ({ page, guestUser }) => {
await loginUser(page, guestUser);
await page.goto('/tasks/backups');
await waitForLoadingComplete(page);
// Sanity check: ensure guest can access the backups page
await expect(page).toHaveURL(/\/tasks\/backups/);
// Guest users should not see any Create Backup button
const createButton = page.locator(SELECTORS.createBackupButton);
await expect(createButton).toHaveCount(0, { timeout: 5000 });
});
});
// =========================================================================
// Backup List Display Tests (4 tests)
// =========================================================================
test.describe('Backup List Display', () => {
test('should display empty state when no backups exist', async ({ page, authenticatedUser }) => {
await loginUser(page, authenticatedUser);
// Mock empty response
await page.route('**/api/v1/backups', async (route) => {
if (route.request().method() === 'GET') {
await route.fulfill({ status: 200, json: [] });
} else {
await route.continue();
}
});
await page.goto('/tasks/backups');
await waitForLoadingComplete(page);
const emptyState = page.locator(SELECTORS.emptyState);
await expect(emptyState).toBeVisible();
});
test('should display list of existing backups', async ({ page, authenticatedUser }) => {
await loginUser(page, authenticatedUser);
// Mock backup list response
await page.route('**/api/v1/backups', async (route) => {
if (route.request().method() === 'GET') {
await route.fulfill({ status: 200, json: mockBackups });
} else {
await route.continue();
}
});
await page.goto('/tasks/backups');
await waitForLoadingComplete(page);
// Verify both backups are displayed
await expect(page.getByText('backup_2024-01-15_120000.tar.gz')).toBeVisible();
await expect(page.getByText('backup_2024-01-14_120000.tar.gz')).toBeVisible();
// Verify size is displayed (formatted)
await expect(page.getByText('1.00 MB')).toBeVisible();
await expect(page.getByText('2.00 MB')).toBeVisible();
});
test('should sort backups by date newest first', async ({ page, authenticatedUser }) => {
await loginUser(page, authenticatedUser);
// Mock backup list with specific order
const sortedBackups: BackupFile[] = [
{ filename: 'backup_2024-01-16_120000.tar.gz', size: 512000, time: '2024-01-16T12:00:00Z' },
{ filename: 'backup_2024-01-15_120000.tar.gz', size: 1048576, time: '2024-01-15T12:00:00Z' },
{ filename: 'backup_2024-01-14_120000.tar.gz', size: 2097152, time: '2024-01-14T12:00:00Z' },
];
await page.route('**/api/v1/backups', async (route) => {
if (route.request().method() === 'GET') {
await route.fulfill({ status: 200, json: sortedBackups });
} else {
await route.continue();
}
});
await page.goto('/tasks/backups');
await waitForLoadingComplete(page);
// Get all backup filenames in order
const rows = page.locator('table tbody tr, [role="row"]').filter({ hasNot: page.locator('th') });
const firstRow = rows.first();
const lastRow = rows.last();
// Newest backup should appear first
await expect(firstRow).toContainText('backup_2024-01-16');
await expect(lastRow).toContainText('backup_2024-01-14');
});
test('should show loading skeleton while fetching', async ({ page, authenticatedUser }) => {
await loginUser(page, authenticatedUser);
// Delay the response to observe loading state
await page.route('**/api/v1/backups', async (route) => {
if (route.request().method() === 'GET') {
await new Promise((resolve) => setTimeout(resolve, 1000));
await route.fulfill({ status: 200, json: mockBackups });
} else {
await route.continue();
}
});
await page.goto('/tasks/backups');
// Should show loading skeleton initially
const skeleton = page.locator(SELECTORS.loadingSkeleton);
await expect(skeleton).toBeVisible({ timeout: 2000 });
// After loading completes, skeleton should disappear
await waitForLoadingComplete(page, { timeout: 5000 });
await expect(skeleton).not.toBeVisible();
});
});
// =========================================================================
// Create Backup Flow Tests (5 tests)
// =========================================================================
test.describe('Create Backup Flow', () => {
test('should create a new backup successfully', async ({ page, adminUser }) => {
await loginUser(page, adminUser);
const newBackup: BackupFile = {
filename: 'backup_2024-01-16_120000.tar.gz',
size: 512000,
time: new Date().toISOString(),
};
let postCalled = false;
await page.route('**/api/v1/backups', async (route) => {
if (route.request().method() === 'POST') {
postCalled = true;
await route.fulfill({ status: 201, json: newBackup });
} else if (route.request().method() === 'GET') {
await route.fulfill({ status: 200, json: mockBackups });
} else {
await route.continue();
}
});
await page.goto('/tasks/backups');
await waitForLoadingComplete(page);
// Click create backup button and wait for API response concurrently
await Promise.all([
page.waitForResponse(r => r.url().includes('/api/v1/backups') && r.request().method() === 'POST' && r.status() === 201),
page.click(SELECTORS.createBackupButton),
]);
// Verify POST was called
expect(postCalled).toBe(true);
});
test('should show success toast after backup creation', async ({ page, adminUser }) => {
await loginUser(page, adminUser);
const newBackup: BackupFile = {
filename: 'backup_2024-01-16_120000.tar.gz',
size: 512000,
time: new Date().toISOString(),
};
await page.route('**/api/v1/backups', async (route) => {
if (route.request().method() === 'POST') {
await route.fulfill({ status: 201, json: newBackup });
} else if (route.request().method() === 'GET') {
await route.fulfill({ status: 200, json: mockBackups });
} else {
await route.continue();
}
});
await page.goto('/tasks/backups');
await waitForLoadingComplete(page);
// Click create backup button
await page.click(SELECTORS.createBackupButton);
// Wait for success toast
await waitForToast(page, /success|created/i, { type: 'success' });
});
test('should update backup list with new backup', async ({ page, adminUser }) => {
await loginUser(page, adminUser);
const newBackup: BackupFile = {
filename: 'backup_2024-01-16_120000.tar.gz',
size: 512000,
time: new Date().toISOString(),
};
let requestCount = 0;
await page.route('**/api/v1/backups', async (route) => {
if (route.request().method() === 'POST') {
await route.fulfill({ status: 201, json: newBackup });
} else if (route.request().method() === 'GET') {
requestCount++;
// Return updated list after creation
const backups = requestCount > 1 ? [newBackup, ...mockBackups] : mockBackups;
await route.fulfill({ status: 200, json: backups });
} else {
await route.continue();
}
});
await page.goto('/tasks/backups');
await waitForLoadingComplete(page);
// Initial state - should not show new backup
await expect(page.getByText('backup_2024-01-16_120000.tar.gz')).not.toBeVisible();
// Click create backup button
await page.click(SELECTORS.createBackupButton);
// Wait for success toast (which indicates the backup was created)
await waitForToast(page, /success|created/i, { type: 'success' });
// New backup should now be visible after list refresh
await expect(page.getByText('backup_2024-01-16_120000.tar.gz')).toBeVisible({ timeout: 5000 });
});
test('should disable create button while in progress', async ({ page, adminUser }) => {
await loginUser(page, adminUser);
await page.route('**/api/v1/backups', async (route) => {
if (route.request().method() === 'POST') {
// Delay response to observe disabled state
await new Promise((resolve) => setTimeout(resolve, 1000));
await route.fulfill({
status: 201,
json: { filename: 'test.tar.gz', size: 100, time: new Date().toISOString() },
});
} else if (route.request().method() === 'GET') {
await route.fulfill({ status: 200, json: mockBackups });
} else {
await route.continue();
}
});
await page.goto('/tasks/backups');
await waitForLoadingComplete(page);
const createButton = page.getByRole('button', { name: /create backup/i }).first();
const createResponsePromise = page.waitForResponse(
(response) =>
response.url().includes('/api/v1/backups') &&
response.request().method() === 'POST' &&
response.status() === 201
);
// Click create button
await createButton.click();
// Button should be disabled during request
await expect(createButton).toBeDisabled();
// Wait for API response
await createResponsePromise;
// After completion, button should be enabled again
await expect(createButton).toBeEnabled({ timeout: 5000 });
});
test('should handle backup creation failure', async ({ page, adminUser }) => {
await loginUser(page, adminUser);
await page.route('**/api/v1/backups', async (route) => {
if (route.request().method() === 'POST') {
await route.fulfill({
status: 500,
json: { error: 'Internal server error' },
});
} else if (route.request().method() === 'GET') {
await route.fulfill({ status: 200, json: mockBackups });
} else {
await route.continue();
}
});
await page.goto('/tasks/backups');
await waitForLoadingComplete(page);
// Click create backup button
await page.click(SELECTORS.createBackupButton);
// Wait for error toast
await waitForToast(page, /error|failed/i, { type: 'error' });
});
});
// =========================================================================
// Delete Backup Tests (3 tests)
// =========================================================================
test.describe('Delete Backup', () => {
test('should show confirmation dialog before deleting', async ({ page, adminUser }) => {
await loginUser(page, adminUser);
await page.route('**/api/v1/backups', async (route) => {
if (route.request().method() === 'GET') {
await route.fulfill({ status: 200, json: mockBackups });
} else {
await route.continue();
}
});
await page.goto('/tasks/backups');
await waitForLoadingComplete(page);
// Click delete on first backup
const deleteButton = page.locator(SELECTORS.deleteBtn).first();
await deleteButton.click();
// Verify dialog appears
const dialog = page.locator(SELECTORS.confirmDialog);
await expect(dialog).toBeVisible();
// Verify dialog contains confirmation text
await expect(dialog).toContainText(/delete|confirm/i);
});
test('should delete backup after confirmation', async ({ page, adminUser }) => {
await loginUser(page, adminUser);
const filename = 'backup_2024-01-15_120000.tar.gz';
let deleteRequested = false;
await page.route('**/api/v1/backups', async (route) => {
if (route.request().method() === 'GET') {
await route.fulfill({ status: 200, json: mockBackups });
} else {
await route.continue();
}
});
await page.route(`**/api/v1/backups/${filename}`, async (route) => {
if (route.request().method() === 'DELETE') {
deleteRequested = true;
await route.fulfill({ status: 204 });
} else {
await route.continue();
}
});
await page.goto('/tasks/backups');
await waitForLoadingComplete(page);
// Click delete on first backup
const deleteButton = page.locator(SELECTORS.deleteBtn).first();
await deleteButton.click();
// Wait for dialog
const dialog = page.locator(SELECTORS.confirmDialog);
await expect(dialog).toBeVisible();
// Click confirm button and wait for DELETE request concurrently
const confirmButton = dialog.locator(SELECTORS.confirmButton);
await Promise.all([
page.waitForResponse(r => r.url().includes(`/api/v1/backups/${filename}`) && r.request().method() === 'DELETE' && r.status() === 204),
confirmButton.click(),
]);
// Verify DELETE was called
expect(deleteRequested).toBe(true);
// Dialog should close
await expect(dialog).not.toBeVisible();
});
test('should cancel delete when clicking cancel button', async ({ page, adminUser }) => {
await loginUser(page, adminUser);
let deleteRequested = false;
await page.route('**/api/v1/backups', async (route) => {
if (route.request().method() === 'GET') {
await route.fulfill({ status: 200, json: mockBackups });
} else {
await route.continue();
}
});
await page.route('**/api/v1/backups/*', async (route) => {
if (route.request().method() === 'DELETE') {
deleteRequested = true;
await route.fulfill({ status: 204 });
} else {
await route.continue();
}
});
await page.goto('/tasks/backups');
await waitForLoadingComplete(page);
// Click delete on first backup
const deleteButton = page.locator(SELECTORS.deleteBtn).first();
await deleteButton.click();
// Wait for dialog
const dialog = page.locator(SELECTORS.confirmDialog);
await expect(dialog).toBeVisible();
// Click cancel button
const cancelButton = dialog.locator(SELECTORS.cancelButton);
await cancelButton.click();
// Dialog should close
await expect(dialog).not.toBeVisible();
// DELETE should not have been called
expect(deleteRequested).toBe(false);
// Backup should still be visible
await expect(page.getByText('backup_2024-01-15_120000.tar.gz')).toBeVisible();
});
});
// =========================================================================
// Download Backup Tests (2 tests)
// =========================================================================
test.describe('Download Backup', () => {
test('should download backup file successfully', async ({ page, adminUser }) => {
await loginUser(page, adminUser);
const filename = 'backup_2024-01-15_120000.tar.gz';
await page.route('**/api/v1/backups', async (route) => {
if (route.request().method() === 'GET') {
await route.fulfill({ status: 200, json: mockBackups });
} else {
await route.continue();
}
});
// Mock download endpoint
await page.route(`**/api/v1/backups/${filename}/download`, async (route) => {
await route.fulfill({
status: 200,
headers: {
'Content-Type': 'application/gzip',
'Content-Disposition': `attachment; filename="${filename}"`,
},
body: Buffer.from('mock backup content'),
});
});
await page.goto('/tasks/backups');
await waitForLoadingComplete(page);
// Track download event - The component uses window.location.href for downloads
// Since Playwright can't track navigation-based downloads directly,
// we verify the download button triggers the correct action
const downloadButton = page.locator(SELECTORS.downloadBtn).first();
await expect(downloadButton).toBeVisible();
await expect(downloadButton).toBeEnabled();
// For actual download verification in a real scenario, we'd use:
// const downloadPromise = page.waitForEvent('download');
// await downloadButton.click();
// const download = await downloadPromise;
// expect(download.suggestedFilename()).toBe(filename);
// For this test, we verify the button is clickable and properly rendered
const buttonTitle = await downloadButton.getAttribute('title');
expect(buttonTitle).toBeTruthy();
});
test('should show error toast when download fails', async ({ page, adminUser }) => {
await loginUser(page, adminUser);
const filename = 'backup_2024-01-15_120000.tar.gz';
await page.route('**/api/v1/backups', async (route) => {
if (route.request().method() === 'GET') {
await route.fulfill({ status: 200, json: mockBackups });
} else {
await route.continue();
}
});
// Mock download endpoint with failure
await page.route(`**/api/v1/backups/${filename}/download`, async (route) => {
await route.fulfill({
status: 404,
json: { error: 'Backup file not found' },
});
});
await page.goto('/tasks/backups');
await waitForLoadingComplete(page);
// The download button uses window.location.href which navigates away
// For error handling tests, we verify the download button is present
// In the actual component, download errors would be handled differently
// since window.location.href navigation can't be caught by JavaScript
const downloadButton = page.locator(SELECTORS.downloadBtn).first();
await expect(downloadButton).toBeVisible();
// Note: The Backups.tsx component uses window.location.href for downloads,
// which means download errors result in browser navigation to an error page
// rather than a toast notification. This is a known limitation of the current
// implementation. A better approach would use fetch() with blob download.
});
});
});