diff --git a/frontend/e2e/workflows/5-offline-sync.spec.ts b/frontend/e2e/workflows/5-offline-sync.spec.ts new file mode 100644 index 00000000..09181e45 --- /dev/null +++ b/frontend/e2e/workflows/5-offline-sync.spec.ts @@ -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`); + }); +});