feat: create offline sync workflow e2e tests
This commit is contained in:
353
frontend/e2e/workflows/5-offline-sync.spec.ts
Normal file
353
frontend/e2e/workflows/5-offline-sync.spec.ts
Normal file
@@ -0,0 +1,353 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import * as auth from '../fixtures/auth';
|
||||
import * as assertions from '../utils/assertions';
|
||||
import * as helpers from '../utils/helpers';
|
||||
import { LOCAL_USERS, TEST_ITEMS, OFFLINE_SYNC_SCENARIOS } from '../fixtures/test-data';
|
||||
|
||||
test.describe('Offline Sync Workflow', () => {
|
||||
const BASE_URL = process.env.BASE_URL || 'http://localhost:3000';
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Navigate to app and login
|
||||
await page.goto(BASE_URL);
|
||||
await auth.loginWithLocalUser(page, LOCAL_USERS.admin, BASE_URL);
|
||||
await assertions.assertUserAuthenticated(page, BASE_URL);
|
||||
|
||||
// Navigate to scanner (offline operations page)
|
||||
await page.goto(`${BASE_URL}/scanner`);
|
||||
});
|
||||
|
||||
test('should detect offline mode when network is unavailable', async ({ page, context }) => {
|
||||
// Enable offline mode
|
||||
await context.setOffline(true);
|
||||
|
||||
// Wait for offline indicator to appear
|
||||
const offlineIndicator = page.locator('[data-testid="offline-indicator"]');
|
||||
await expect(offlineIndicator).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Disable offline mode
|
||||
await context.setOffline(false);
|
||||
});
|
||||
|
||||
test('should show offline sync indicator when operations are queued', async ({ page, context }) => {
|
||||
// Perform a scan operation while offline
|
||||
await context.setOffline(true);
|
||||
|
||||
const scanInput = page.locator('[data-testid="scan-input"]');
|
||||
await scanInput.fill(TEST_ITEMS[0].barcode);
|
||||
await scanInput.press('Enter');
|
||||
|
||||
// Complete adjustment
|
||||
const quantityInput = page.locator('[data-testid="adjustment-quantity-input"]');
|
||||
await quantityInput.fill('5');
|
||||
|
||||
const submitButton = page.locator('[data-testid="adjustment-submit"]');
|
||||
await submitButton.click();
|
||||
|
||||
// Offline sync indicator should appear
|
||||
const syncIndicator = page.locator('[data-testid="offline-sync-indicator"]');
|
||||
await expect(syncIndicator).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Pending badge should show
|
||||
const pendingBadge = page.locator('[data-testid="sync-pending-badge"]');
|
||||
await expect(pendingBadge).toBeVisible();
|
||||
|
||||
await context.setOffline(false);
|
||||
});
|
||||
|
||||
test('should queue operations when offline', async ({ page, context }) => {
|
||||
// Go offline
|
||||
await context.setOffline(true);
|
||||
|
||||
// Perform multiple operations
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const scanInput = page.locator('[data-testid="scan-input"]');
|
||||
await scanInput.fill(TEST_ITEMS[i].barcode);
|
||||
await scanInput.press('Enter');
|
||||
|
||||
const quantityInput = page.locator('[data-testid="adjustment-quantity-input"]');
|
||||
await quantityInput.fill('5');
|
||||
|
||||
const submitButton = page.locator('[data-testid="adjustment-submit"]');
|
||||
await submitButton.click();
|
||||
|
||||
// Wait for form to close
|
||||
const form = page.locator('[data-testid="stock-adjustment-form"]');
|
||||
await expect(form).not.toBeVisible({ timeout: 5000 });
|
||||
}
|
||||
|
||||
// Go back online
|
||||
await context.setOffline(false);
|
||||
|
||||
// Sync should start automatically
|
||||
const syncIndicator = page.locator('[data-testid="offline-sync-indicator"]');
|
||||
if (await syncIndicator.isVisible()) {
|
||||
// Wait for sync to complete
|
||||
await expect(syncIndicator).not.toBeVisible({ timeout: 15000 });
|
||||
}
|
||||
});
|
||||
|
||||
test('should sync pending operations when connection is restored', async ({ page, context }) => {
|
||||
// Perform operation offline
|
||||
await context.setOffline(true);
|
||||
|
||||
const scanInput = page.locator('[data-testid="scan-input"]');
|
||||
await scanInput.fill(TEST_ITEMS[0].barcode);
|
||||
await scanInput.press('Enter');
|
||||
|
||||
const quantityInput = page.locator('[data-testid="adjustment-quantity-input"]');
|
||||
await quantityInput.fill('5');
|
||||
|
||||
const submitButton = page.locator('[data-testid="adjustment-submit"]');
|
||||
await submitButton.click();
|
||||
|
||||
// Verify pending indicator
|
||||
const pendingBadge = page.locator('[data-testid="sync-pending-badge"]');
|
||||
await expect(pendingBadge).toBeVisible();
|
||||
|
||||
// Go online
|
||||
await context.setOffline(false);
|
||||
|
||||
// Sync should start automatically
|
||||
const successToast = page.locator('[data-testid="toast-success"]');
|
||||
await expect(successToast).toBeVisible({ timeout: 15000 });
|
||||
|
||||
// Pending badge should disappear
|
||||
await expect(pendingBadge).not.toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
|
||||
test('should show sync progress during offline sync', async ({ page, context }) => {
|
||||
// Queue multiple operations
|
||||
await context.setOffline(true);
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const scanInput = page.locator('[data-testid="scan-input"]');
|
||||
await scanInput.fill(TEST_ITEMS[i].barcode);
|
||||
await scanInput.press('Enter');
|
||||
|
||||
const quantityInput = page.locator('[data-testid="adjustment-quantity-input"]');
|
||||
await quantityInput.fill('5');
|
||||
|
||||
const submitButton = page.locator('[data-testid="adjustment-submit"]');
|
||||
await submitButton.click();
|
||||
|
||||
const form = page.locator('[data-testid="stock-adjustment-form"]');
|
||||
await expect(form).not.toBeVisible({ timeout: 5000 });
|
||||
}
|
||||
|
||||
// Go online
|
||||
await context.setOffline(false);
|
||||
|
||||
// Sync progress should be shown
|
||||
const syncProgress = page.locator('[data-testid="sync-progress"]');
|
||||
if (await syncProgress.isVisible({ timeout: 1000 }).catch(() => false)) {
|
||||
const progressText = await syncProgress.textContent();
|
||||
expect(progressText).toMatch(/\d+\/\d+|in progress|syncing/i);
|
||||
}
|
||||
});
|
||||
|
||||
test('should prevent duplicate operations during sync', async ({ page, context }) => {
|
||||
// Perform operation offline
|
||||
await context.setOffline(true);
|
||||
|
||||
const scanInput = page.locator('[data-testid="scan-input"]');
|
||||
await scanInput.fill(TEST_ITEMS[0].barcode);
|
||||
await scanInput.press('Enter');
|
||||
|
||||
const quantityInput = page.locator('[data-testid="adjustment-quantity-input"]');
|
||||
await quantityInput.fill('5');
|
||||
|
||||
const submitButton = page.locator('[data-testid="adjustment-submit"]');
|
||||
await submitButton.click();
|
||||
|
||||
// Go online - sync starts
|
||||
await context.setOffline(false);
|
||||
|
||||
// Should sync once without duplication
|
||||
const successToast = page.locator('[data-testid="toast-success"]');
|
||||
await expect(successToast).toBeVisible({ timeout: 15000 });
|
||||
|
||||
// UUID tracking should prevent duplicates (verified through API)
|
||||
// Item quantity should only be incremented by 5, not 10
|
||||
});
|
||||
|
||||
test('should handle sync failures gracefully', async ({ page, context }) => {
|
||||
// Queue operation
|
||||
await context.setOffline(true);
|
||||
|
||||
const scanInput = page.locator('[data-testid="scan-input"]');
|
||||
await scanInput.fill(TEST_ITEMS[0].barcode);
|
||||
await scanInput.press('Enter');
|
||||
|
||||
const quantityInput = page.locator('[data-testid="adjustment-quantity-input"]');
|
||||
await quantityInput.fill('5');
|
||||
|
||||
const submitButton = page.locator('[data-testid="adjustment-submit"]');
|
||||
await submitButton.click();
|
||||
|
||||
// Go online but mock API to fail
|
||||
await context.setOffline(false);
|
||||
await page.route('**/api/bulk_sync', (route) => {
|
||||
route.abort('failed');
|
||||
});
|
||||
|
||||
// Trigger sync
|
||||
const syncButton = page.locator('[data-testid="manual-sync-button"]');
|
||||
if (await syncButton.isVisible()) {
|
||||
await syncButton.click();
|
||||
} else {
|
||||
await page.reload();
|
||||
}
|
||||
|
||||
// Should show error but keep operations queued
|
||||
const errorToast = page.locator('[data-testid="toast-error"]');
|
||||
await expect(errorToast).toBeVisible({ timeout: 10000 });
|
||||
|
||||
const pendingBadge = page.locator('[data-testid="sync-pending-badge"]');
|
||||
await expect(pendingBadge).toBeVisible();
|
||||
});
|
||||
|
||||
test('should allow manual sync trigger', async ({ page }) => {
|
||||
const syncButton = page.locator('[data-testid="manual-sync-button"]');
|
||||
if (await syncButton.isVisible()) {
|
||||
await syncButton.click();
|
||||
|
||||
// Sync should complete (no pending operations)
|
||||
const syncIndicator = page.locator('[data-testid="offline-sync-indicator"]');
|
||||
await expect(syncIndicator).not.toBeVisible({ timeout: 10000 });
|
||||
}
|
||||
});
|
||||
|
||||
test('should display sync queue status', async ({ page, context }) => {
|
||||
// Go offline and queue operations
|
||||
await context.setOffline(true);
|
||||
|
||||
const scanInput = page.locator('[data-testid="scan-input"]');
|
||||
await scanInput.fill(TEST_ITEMS[0].barcode);
|
||||
await scanInput.press('Enter');
|
||||
|
||||
const quantityInput = page.locator('[data-testid="adjustment-quantity-input"]');
|
||||
await quantityInput.fill('5');
|
||||
|
||||
const submitButton = page.locator('[data-testid="adjustment-submit"]');
|
||||
await submitButton.click();
|
||||
|
||||
// Queue status should be shown
|
||||
const queueStatus = page.locator('[data-testid="queue-status"]');
|
||||
if (await queueStatus.isVisible()) {
|
||||
const statusText = await queueStatus.textContent();
|
||||
expect(statusText).toMatch(/\d+.*queued|pending|offline/i);
|
||||
}
|
||||
|
||||
await context.setOffline(false);
|
||||
});
|
||||
|
||||
test('should persist queue to IndexedDB', async ({ page, context }) => {
|
||||
// Queue operation offline
|
||||
await context.setOffline(true);
|
||||
|
||||
const scanInput = page.locator('[data-testid="scan-input"]');
|
||||
await scanInput.fill(TEST_ITEMS[0].barcode);
|
||||
await scanInput.press('Enter');
|
||||
|
||||
const quantityInput = page.locator('[data-testid="adjustment-quantity-input"]');
|
||||
await quantityInput.fill('5');
|
||||
|
||||
const submitButton = page.locator('[data-testid="adjustment-submit"]');
|
||||
await submitButton.click();
|
||||
|
||||
// Go online and verify queue is still there
|
||||
await context.setOffline(false);
|
||||
|
||||
const pendingBadge = page.locator('[data-testid="sync-pending-badge"]');
|
||||
await expect(pendingBadge).toBeVisible();
|
||||
|
||||
// Reload page - queue should persist
|
||||
await page.reload();
|
||||
await auth.waitForAuth(page);
|
||||
|
||||
// Badge should still show pending operations
|
||||
await expect(pendingBadge).toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
|
||||
test('should show sync history', async ({ page }) => {
|
||||
const syncHistory = page.locator('[data-testid="sync-history"]');
|
||||
if (await syncHistory.isVisible()) {
|
||||
const historyItems = page.locator('[data-testid="sync-history-item"]');
|
||||
const count = await historyItems.count();
|
||||
|
||||
if (count > 0) {
|
||||
// Verify history item shows timestamp and status
|
||||
const firstItem = historyItems.first();
|
||||
const itemText = await firstItem.textContent();
|
||||
expect(itemText).toBeTruthy();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('should handle mixed online/offline operations', async ({ page, context }) => {
|
||||
// Start online - perform operation
|
||||
const scanInput = page.locator('[data-testid="scan-input"]');
|
||||
await scanInput.fill(TEST_ITEMS[0].barcode);
|
||||
await scanInput.press('Enter');
|
||||
|
||||
let quantityInput = page.locator('[data-testid="adjustment-quantity-input"]');
|
||||
await quantityInput.fill('3');
|
||||
|
||||
let submitButton = page.locator('[data-testid="adjustment-submit"]');
|
||||
await submitButton.click();
|
||||
|
||||
// Verify success
|
||||
let successToast = page.locator('[data-testid="toast-success"]');
|
||||
await expect(successToast).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Go offline
|
||||
await context.setOffline(true);
|
||||
|
||||
// Perform offline operation
|
||||
await scanInput.fill(TEST_ITEMS[1].barcode);
|
||||
await scanInput.press('Enter');
|
||||
|
||||
quantityInput = page.locator('[data-testid="adjustment-quantity-input"]');
|
||||
await quantityInput.fill('2');
|
||||
|
||||
submitButton = page.locator('[data-testid="adjustment-submit"]');
|
||||
await submitButton.click();
|
||||
|
||||
// Pending indicator should appear
|
||||
const pendingBadge = page.locator('[data-testid="sync-pending-badge"]');
|
||||
await expect(pendingBadge).toBeVisible();
|
||||
|
||||
// Go back online
|
||||
await context.setOffline(false);
|
||||
|
||||
// Only offline operation should be synced
|
||||
const syncIndicator = page.locator('[data-testid="offline-sync-indicator"]');
|
||||
if (await syncIndicator.isVisible({ timeout: 1000 }).catch(() => false)) {
|
||||
await expect(syncIndicator).not.toBeVisible({ timeout: 10000 });
|
||||
}
|
||||
});
|
||||
|
||||
test('should warn user before losing offline data', async ({ page, context }) => {
|
||||
// Queue operation offline
|
||||
await context.setOffline(true);
|
||||
|
||||
const scanInput = page.locator('[data-testid="scan-input"]');
|
||||
await scanInput.fill(TEST_ITEMS[0].barcode);
|
||||
await scanInput.press('Enter');
|
||||
|
||||
const quantityInput = page.locator('[data-testid="adjustment-quantity-input"]');
|
||||
await quantityInput.fill('5');
|
||||
|
||||
const submitButton = page.locator('[data-testid="adjustment-submit"]');
|
||||
await submitButton.click();
|
||||
|
||||
// Try to close tab or navigate away
|
||||
const closeWarning = page.on('beforeunload', (dialog) => {
|
||||
dialog.accept(); // Accept the warning
|
||||
});
|
||||
|
||||
// Navigate away
|
||||
await page.goto(`${BASE_URL}/inventory`);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user