diff --git a/frontend/e2e/workflows/7-ai-extraction-autosave.spec.ts b/frontend/e2e/workflows/7-ai-extraction-autosave.spec.ts new file mode 100644 index 00000000..8765e409 --- /dev/null +++ b/frontend/e2e/workflows/7-ai-extraction-autosave.spec.ts @@ -0,0 +1,327 @@ +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 } from '../fixtures/test-data'; + +test.describe('AI Extraction + Auto-Photo-Save Flow', () => { + 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 new item creation (AI extraction) + await page.goto(`${BASE_URL}/inventory/new`); + + // Wait for AI wizard to be ready + const aiWizard = page.locator('[data-testid="ai-onboarding-wizard"]'); + await expect(aiWizard).toBeVisible({ timeout: 5000 }); + }); + + test('should auto-save photo after successful AI identification and item creation', async ({ + page, + }) => { + // 1. Click capture button to trigger AI extraction + const captureButton = page.locator('[data-testid="capture-button"]'); + await expect(captureButton).toBeVisible(); + await captureButton.click(); + + // 2. Wait for AI extraction to complete and show results + const resultsForm = page.locator('[data-testid="extraction-results-form"]'); + await expect(resultsForm).toBeVisible({ timeout: 15000 }); + + // 3. Verify extracted data is shown + const nameField = page.locator('[data-testid="extracted-name"]'); + await expect(nameField).toBeVisible(); + const extractedName = await nameField.inputValue(); + expect(extractedName).toBeTruthy(); + expect(extractedName.length).toBeGreaterThan(0); + + // 4. Confirm extraction to create item + const confirmButton = page.locator('[data-testid="confirm-extraction"]'); + await confirmButton.click(); + + // 5. Wait for success toast and item creation + const successToast = page.locator('[data-testid="toast-success"]'); + await expect(successToast).toBeVisible({ timeout: 10000 }); + + // Verify toast contains success message about item creation + const toastText = await successToast.textContent(); + expect(toastText?.toLowerCase()).toContain('created'); + + // 6. Wait for redirect to inventory + await page.waitForURL(/inventory|items/, { timeout: 10000 }); + + // 7. Navigate to inventory to verify photo appears + await page.goto(`${BASE_URL}/inventory`); + const inventoryTable = page.locator('[data-testid="inventory-table"]'); + await expect(inventoryTable).toBeVisible({ timeout: 5000 }); + + // 8. Get the first item row (should be the newly created item) + const firstItemRow = inventoryTable.locator('tbody tr').first(); + await expect(firstItemRow).toBeVisible(); + + // 9. Verify photo thumbnail is present + const photoThumbnail = firstItemRow.locator('[data-testid="item-photo-thumbnail"]'); + await expect(photoThumbnail).toBeVisible({ timeout: 5000 }); + + // 10. Click photo to open modal and verify full-res photo + await photoThumbnail.click(); + + const photoModal = page.locator('[data-testid="photo-modal"]'); + await expect(photoModal).toBeVisible({ timeout: 5000 }); + + const fullPhoto = photoModal.locator('img'); + await expect(fullPhoto).toBeVisible(); + + // Verify the image has a valid src attribute (should contain /photos/ path) + const imgSrc = await fullPhoto.getAttribute('src'); + expect(imgSrc).toBeTruthy(); + expect(imgSrc).toMatch(/\/photos\/|\/images\/|data:image/); + + // 11. Close modal by clicking outside or close button + const closeButton = photoModal.locator('[data-testid="modal-close"]'); + if (await closeButton.isVisible()) { + await closeButton.click(); + } else { + // Click outside the modal + await page.click('[data-testid="photo-modal-backdrop"]'); + } + + await expect(photoModal).not.toBeVisible({ timeout: 3000 }); + }); + + test('should display photo metadata in inventory after auto-save', async ({ page }) => { + // 1. Capture and extract + const captureButton = page.locator('[data-testid="capture-button"]'); + await captureButton.click(); + + const resultsForm = page.locator('[data-testid="extraction-results-form"]'); + await expect(resultsForm).toBeVisible({ timeout: 15000 }); + + // 2. Confirm extraction + const confirmButton = page.locator('[data-testid="confirm-extraction"]'); + await confirmButton.click(); + + const successToast = page.locator('[data-testid="toast-success"]'); + await expect(successToast).toBeVisible({ timeout: 10000 }); + + await page.waitForURL(/inventory|items/, { timeout: 10000 }); + + // 3. Navigate to inventory + await page.goto(`${BASE_URL}/inventory`); + const inventoryTable = page.locator('[data-testid="inventory-table"]'); + await expect(inventoryTable).toBeVisible({ timeout: 5000 }); + + // 4. Find the newly created item row + const firstItemRow = inventoryTable.locator('tbody tr').first(); + await expect(firstItemRow).toBeVisible(); + + // 5. Verify photo column shows photo indicator or date + const photoCell = firstItemRow.locator('[data-testid="item-photo-cell"]'); + if (await photoCell.isVisible()) { + const photoIndicator = photoCell.locator('[data-testid="item-photo-thumbnail"]'); + await expect(photoIndicator).toBeVisible(); + + // Verify photo has visual indication (image or icon) + const hasImage = await photoIndicator.locator('img').isVisible(); + const hasIcon = await photoIndicator.locator('[role="img"]').isVisible(); + expect(hasImage || hasIcon).toBe(true); + } + }); + + test('should handle photo upload failure gracefully without blocking item creation', async ({ + page, + }) => { + // Mock photo upload to fail + await page.route('**/items/*/photos', (route) => { + route.abort('failed'); + }); + + // 1. Capture and extract + const captureButton = page.locator('[data-testid="capture-button"]'); + await captureButton.click(); + + const resultsForm = page.locator('[data-testid="extraction-results-form"]'); + await expect(resultsForm).toBeVisible({ timeout: 15000 }); + + // 2. Confirm extraction (photo upload will fail, but item should still be created) + const confirmButton = page.locator('[data-testid="confirm-extraction"]'); + await confirmButton.click(); + + // 3. Should show success for item creation (despite photo save failure) + const successToast = page.locator('[data-testid="toast-success"]'); + await expect(successToast).toBeVisible({ timeout: 10000 }); + + const toastText = await successToast.textContent(); + expect(toastText?.toLowerCase()).toContain('created'); + + // 4. Should NOT show critical error blocking the operation + const errorToast = page.locator('[data-testid="toast-error"]'); + const isErrorVisible = await errorToast.isVisible().catch(() => false); + // Error about photo might be shown as warning, not blocking error + expect(isErrorVisible).toBe(false); + + await page.waitForURL(/inventory|items/, { timeout: 10000 }); + + // 5. Navigate to inventory + await page.goto(`${BASE_URL}/inventory`); + const inventoryTable = page.locator('[data-testid="inventory-table"]'); + await expect(inventoryTable).toBeVisible({ timeout: 5000 }); + + // 6. Verify item still exists without photo + const firstItemRow = inventoryTable.locator('tbody tr').first(); + await expect(firstItemRow).toBeVisible(); + + // Photo should be absent or show placeholder + const photoThumbnail = firstItemRow.locator('[data-testid="item-photo-thumbnail"]'); + const photoVisible = await photoThumbnail.isVisible().catch(() => false); + // Photo might not be visible or might show placeholder + expect(photoVisible).toBe(false); + }); + + test('should preserve photo even when item data is edited post-save', async ({ page }) => { + // 1. Capture and extract + const captureButton = page.locator('[data-testid="capture-button"]'); + await captureButton.click(); + + const resultsForm = page.locator('[data-testid="extraction-results-form"]'); + await expect(resultsForm).toBeVisible({ timeout: 15000 }); + + // 2. Get the extracted name for later verification + const nameField = page.locator('[data-testid="extracted-name"]'); + const originalName = await nameField.inputValue(); + + // 3. Confirm extraction + const confirmButton = page.locator('[data-testid="confirm-extraction"]'); + await confirmButton.click(); + + const successToast = page.locator('[data-testid="toast-success"]'); + await expect(successToast).toBeVisible({ timeout: 10000 }); + + await page.waitForURL(/inventory|items/, { timeout: 10000 }); + + // 4. Navigate to inventory + await page.goto(`${BASE_URL}/inventory`); + const inventoryTable = page.locator('[data-testid="inventory-table"]'); + await expect(inventoryTable).toBeVisible({ timeout: 5000 }); + + // 5. Verify photo exists before editing + const firstItemRow = inventoryTable.locator('tbody tr').first(); + const photoThumbnail = firstItemRow.locator('[data-testid="item-photo-thumbnail"]'); + await expect(photoThumbnail).toBeVisible({ timeout: 5000 }); + + // 6. Click on item to open details/edit page + const itemName = firstItemRow.locator('[data-testid="item-name"]'); + await itemName.click(); + + await page.waitForURL(/items\/\d+|inventory\/edit/, { timeout: 10000 }); + + // 7. Find and verify photo is still visible on detail page + const detailPhotoThumbnail = page.locator('[data-testid="item-photo-thumbnail"]'); + await expect(detailPhotoThumbnail).toBeVisible({ timeout: 5000 }); + + // 8. Edit a field (e.g., quantity) + const quantityField = page.locator('[data-testid="item-quantity"]'); + if (await quantityField.isVisible()) { + await quantityField.fill('100'); + + // Save changes + const saveButton = page.locator('[data-testid="save-item-button"]'); + if (await saveButton.isVisible()) { + await saveButton.click(); + + const updateToast = page.locator('[data-testid="toast-success"]'); + await expect(updateToast).toBeVisible({ timeout: 5000 }); + } + } + + // 9. Verify photo still exists after edit + const photoAfterEdit = page.locator('[data-testid="item-photo-thumbnail"]'); + await expect(photoAfterEdit).toBeVisible({ timeout: 5000 }); + }); + + test('should display photo with correct dimensions in modal', async ({ page }) => { + // 1. Capture and extract + const captureButton = page.locator('[data-testid="capture-button"]'); + await captureButton.click(); + + const resultsForm = page.locator('[data-testid="extraction-results-form"]'); + await expect(resultsForm).toBeVisible({ timeout: 15000 }); + + // 2. Confirm extraction + const confirmButton = page.locator('[data-testid="confirm-extraction"]'); + await confirmButton.click(); + + const successToast = page.locator('[data-testid="toast-success"]'); + await expect(successToast).toBeVisible({ timeout: 10000 }); + + await page.waitForURL(/inventory|items/, { timeout: 10000 }); + + // 3. Navigate to inventory + await page.goto(`${BASE_URL}/inventory`); + const inventoryTable = page.locator('[data-testid="inventory-table"]'); + await expect(inventoryTable).toBeVisible({ timeout: 5000 }); + + // 4. Click photo to open modal + const firstItemRow = inventoryTable.locator('tbody tr').first(); + const photoThumbnail = firstItemRow.locator('[data-testid="item-photo-thumbnail"]'); + await photoThumbnail.click(); + + const photoModal = page.locator('[data-testid="photo-modal"]'); + await expect(photoModal).toBeVisible({ timeout: 5000 }); + + // 5. Verify image is properly rendered + const fullPhoto = photoModal.locator('img'); + await expect(fullPhoto).toBeVisible(); + + // 6. Check image dimensions (should be reasonable) + const boundingBox = await fullPhoto.boundingBox(); + expect(boundingBox).toBeTruthy(); + expect(boundingBox!.width).toBeGreaterThan(0); + expect(boundingBox!.height).toBeGreaterThan(0); + + // 7. Verify modal has proper layout (image shouldn't be distorted) + const photoContainer = photoModal.locator('[data-testid="photo-container"]'); + if (await photoContainer.isVisible()) { + const containerBox = await photoContainer.boundingBox(); + expect(containerBox).toBeTruthy(); + // Image should fit within reasonable bounds + expect(containerBox!.width).toBeLessThan(1000); + expect(containerBox!.height).toBeLessThan(1000); + } + }); + + test('should not show duplicate photos for same item', async ({ page }) => { + // 1. Create item with photo + const captureButton = page.locator('[data-testid="capture-button"]'); + await captureButton.click(); + + const resultsForm = page.locator('[data-testid="extraction-results-form"]'); + await expect(resultsForm).toBeVisible({ timeout: 15000 }); + + const confirmButton = page.locator('[data-testid="confirm-extraction"]'); + await confirmButton.click(); + + const successToast = page.locator('[data-testid="toast-success"]'); + await expect(successToast).toBeVisible({ timeout: 10000 }); + + await page.waitForURL(/inventory|items/, { timeout: 10000 }); + + // 2. Navigate to inventory + await page.goto(`${BASE_URL}/inventory`); + const inventoryTable = page.locator('[data-testid="inventory-table"]'); + await expect(inventoryTable).toBeVisible({ timeout: 5000 }); + + // 3. Verify only one photo thumbnail per item + const firstItemRow = inventoryTable.locator('tbody tr').first(); + const photoThumbnails = firstItemRow.locator('[data-testid="item-photo-thumbnail"]'); + const photoCount = await photoThumbnails.count(); + + // Should have exactly 1 photo (or 0 if photo save failed) + expect(photoCount).toBeLessThanOrEqual(1); + }); +});