diff --git a/backend/tests/test_quantity_patch.py b/backend/tests/test_quantity_patch.py new file mode 100644 index 00000000..bda790d2 --- /dev/null +++ b/backend/tests/test_quantity_patch.py @@ -0,0 +1,165 @@ +""" +Test PATCH /items/{item_id} endpoint for quantity adjustment +Ensures audit logging and validation work correctly +""" + +import pytest +import json +from fastapi.testclient import TestClient +from sqlalchemy.orm import Session +from ..models import Item, Category, AuditLog +from ..schemas import Item as ItemSchema +from ..database import Base, engine + + +@pytest.fixture +def test_item(db: Session): + """Create a test item""" + category = Category(name="Test Category") + db.add(category) + db.commit() + + item = Item( + name="Test Item", + category="Test Category", + type="Test Type", + quantity=10, + barcode="TEST123", + part_number="PN-TEST-001" + ) + db.add(item) + db.commit() + db.refresh(item) + return item + + +def test_patch_quantity_success(client: TestClient, test_item: Item, db: Session, auth_token: str): + """Test successful quantity update with audit logging""" + response = client.patch( + f"/items/{test_item.id}", + json={"quantity": 25}, + headers={"Authorization": f"Bearer {auth_token}"} + ) + + assert response.status_code == 200 + data = response.json() + assert data["quantity"] == 25 + assert data["id"] == test_item.id + + # Verify audit log was created + audit_entries = db.query(AuditLog).filter( + AuditLog.target_item_id == test_item.id, + AuditLog.action == "UPDATE_QUANTITY" + ).all() + + assert len(audit_entries) == 1 + audit = audit_entries[0] + assert audit.quantity_change == 15 # 25 - 10 + assert audit.target_item_name == "Test Item" + assert audit.target_item_pn == "PN-TEST-001" + assert "10 → 25" in audit.details + + +def test_patch_quantity_to_zero(client: TestClient, test_item: Item, db: Session, auth_token: str): + """Test setting quantity to zero""" + response = client.patch( + f"/items/{test_item.id}", + json={"quantity": 0}, + headers={"Authorization": f"Bearer {auth_token}"} + ) + + assert response.status_code == 200 + data = response.json() + assert data["quantity"] == 0 + + # Verify audit log has correct delta + audit = db.query(AuditLog).filter( + AuditLog.target_item_id == test_item.id, + AuditLog.action == "UPDATE_QUANTITY" + ).first() + + assert audit.quantity_change == -10 # 0 - 10 + + +def test_patch_quantity_negative_fails(client: TestClient, test_item: Item, auth_token: str): + """Test that negative quantity is rejected""" + response = client.patch( + f"/items/{test_item.id}", + json={"quantity": -5}, + headers={"Authorization": f"Bearer {auth_token}"} + ) + + assert response.status_code == 400 + assert "non-negative" in response.json()["detail"].lower() + + +def test_patch_quantity_missing_field(client: TestClient, test_item: Item, auth_token: str): + """Test that missing quantity field is rejected""" + response = client.patch( + f"/items/{test_item.id}", + json={}, + headers={"Authorization": f"Bearer {auth_token}"} + ) + + assert response.status_code == 400 + assert "quantity" in response.json()["detail"].lower() + + +def test_patch_quantity_invalid_type(client: TestClient, test_item: Item, auth_token: str): + """Test that non-integer quantity is rejected""" + response = client.patch( + f"/items/{test_item.id}", + json={"quantity": "not a number"}, + headers={"Authorization": f"Bearer {auth_token}"} + ) + + assert response.status_code == 400 + assert "integer" in response.json()["detail"].lower() + + +def test_patch_quantity_not_found(client: TestClient, auth_token: str): + """Test that updating non-existent item returns 404""" + response = client.patch( + "/items/99999", + json={"quantity": 10}, + headers={"Authorization": f"Bearer {auth_token}"} + ) + + assert response.status_code == 404 + assert "not found" in response.json()["detail"].lower() + + +def test_patch_quantity_no_auth(client: TestClient, test_item: Item): + """Test that unauthenticated request is rejected""" + response = client.patch( + f"/items/{test_item.id}", + json={"quantity": 25} + ) + + assert response.status_code == 401 + + +def test_patch_quantity_audit_fields(client: TestClient, test_item: Item, db: Session, auth_token: str, current_user_id: str): + """Test that audit log contains all required fields""" + response = client.patch( + f"/items/{test_item.id}", + json={"quantity": 30}, + headers={"Authorization": f"Bearer {auth_token}"} + ) + + assert response.status_code == 200 + + audit = db.query(AuditLog).filter( + AuditLog.target_item_id == test_item.id, + AuditLog.action == "UPDATE_QUANTITY" + ).first() + + assert audit is not None + assert audit.user_id == current_user_id + assert audit.action == "UPDATE_QUANTITY" + assert audit.target_item_id == test_item.id + assert audit.target_item_name == "Test Item" + assert audit.target_item_pn == "PN-TEST-001" + assert audit.target_item_barcode == "TEST123" + assert audit.quantity_change == 20 # 30 - 10 + assert "→" in audit.details # Direction indicator in details diff --git a/frontend/tests/inventory/quick-adjust.test.ts b/frontend/tests/inventory/quick-adjust.test.ts new file mode 100644 index 00000000..d9ea8644 --- /dev/null +++ b/frontend/tests/inventory/quick-adjust.test.ts @@ -0,0 +1,202 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { renderHook, act, waitFor } from '@testing-library/react'; +import { useQuantityAdjustment } from '@/hooks/useQuantityAdjustment'; +import axios from 'axios'; + +// Mock axios +vi.mock('axios'); +const mockedAxios = axios as any; + +describe('Quick Quantity Adjustment - useQuantityAdjustment Hook', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockedAxios.patch.mockClear(); + }); + + it('initializes with correct quantity', () => { + const { result } = renderHook(() => + useQuantityAdjustment('1', 10) + ); + + expect(result.current.quantity).toBe(10); + expect(result.current.isLoading).toBe(false); + expect(result.current.error).toBeNull(); + }); + + it('updates quantity optimistically and confirms with API', async () => { + mockedAxios.patch.mockResolvedValueOnce({ + data: { quantity: 15 }, + }); + + const { result } = renderHook(() => + useQuantityAdjustment('1', 10) + ); + + await act(async () => { + await result.current.adjustQuantity(15); + }); + + expect(result.current.quantity).toBe(15); + expect(result.current.error).toBeNull(); + expect(mockedAxios.patch).toHaveBeenCalledWith( + expect.stringContaining('/items/1'), + { quantity: 15 } + ); + }); + + it('reverts quantity on API failure', async () => { + const error = new Error('Network error'); + mockedAxios.patch.mockRejectedValueOnce(error); + + const { result } = renderHook(() => + useQuantityAdjustment('1', 10) + ); + + await act(async () => { + try { + await result.current.adjustQuantity(15); + } catch { + // Expected to throw + } + }); + + await waitFor(() => { + expect(result.current.quantity).toBe(10); + expect(result.current.error).toBeTruthy(); + }); + }); + + it('validates quantity >= 0', async () => { + const { result } = renderHook(() => + useQuantityAdjustment('1', 10) + ); + + await act(async () => { + await result.current.adjustQuantity(-5); + }); + + expect(result.current.error).toContain('non-negative'); + expect(mockedAxios.patch).not.toHaveBeenCalled(); + }); + + it('validates integer quantities', async () => { + const { result } = renderHook(() => + useQuantityAdjustment('1', 10) + ); + + await act(async () => { + await result.current.adjustQuantity(10.5); + }); + + expect(result.current.error).toContain('integer'); + expect(mockedAxios.patch).not.toHaveBeenCalled(); + }); + + it('resets error on resetError call', async () => { + const error = new Error('Network error'); + mockedAxios.patch.mockRejectedValueOnce(error); + + const { result } = renderHook(() => + useQuantityAdjustment('1', 10) + ); + + await act(async () => { + try { + await result.current.adjustQuantity(15); + } catch { + // Expected to throw + } + }); + + expect(result.current.error).toBeTruthy(); + + act(() => { + result.current.resetError(); + }); + + expect(result.current.error).toBeNull(); + }); + + it('debounces rapid successive calls', async () => { + mockedAxios.patch.mockResolvedValue({ + data: { quantity: 15 }, + }); + + const { result } = renderHook(() => + useQuantityAdjustment('1', 10) + ); + + await act(async () => { + // Queue multiple rapid adjustments + const promises = [ + result.current.adjustQuantity(12), + result.current.adjustQuantity(14), + result.current.adjustQuantity(15), + ]; + + await Promise.all(promises); + }); + + // With debouncing, only the last call should fully process + // (The implementation uses 100ms debounce) + await waitFor(() => { + expect(mockedAxios.patch).toHaveBeenCalled(); + }); + }); + + it('handles API error responses gracefully', async () => { + mockedAxios.patch.mockRejectedValueOnce({ + response: { + data: { + detail: 'Quantity exceeds available stock', + }, + }, + }); + + const { result } = renderHook(() => + useQuantityAdjustment('1', 10) + ); + + await act(async () => { + try { + await result.current.adjustQuantity(999); + } catch { + // Expected + } + }); + + await waitFor(() => { + expect(result.current.error).toBeTruthy(); + expect(result.current.quantity).toBe(10); // Reverted + }); + }); +}); + +describe('Quick Quantity Adjustment - Component Integration', () => { + it('should support UI workflow: tap → +/- → commit', async () => { + // This is a placeholder for E2E/integration test that would: + // 1. Render QuantityDisplay + // 2. Click on quantity to enter edit mode + // 3. Click +/- buttons to adjust + // 4. Press Enter to commit + // 5. Verify API call with correct delta + // Implementation requires React Testing Library setup with actual component rendering + + expect(true).toBe(true); // Placeholder assertion + }); + + it('should show loading state during API call', async () => { + // Placeholder for component loading state test + expect(true).toBe(true); + }); + + it('should show error toast on API failure', async () => { + // Placeholder for error handling test + expect(true).toBe(true); + }); + + it('should cancel edit on Escape key without API call', async () => { + // Placeholder for cancel behavior test + expect(true).toBe(true); + }); +});