feat: create offline sync workflow e2e tests

This commit is contained in:
2026-04-19 07:57:53 +03:00
parent 6cb692eb2b
commit 6c6fe17eba

View 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`);
});
});