354 lines
12 KiB
TypeScript
354 lines
12 KiB
TypeScript
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`);
|
|
});
|
|
});
|