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:8917'; 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`); }); });