diff --git a/backend/tests/test_exports.py b/backend/tests/test_exports.py new file mode 100644 index 00000000..e4589d89 --- /dev/null +++ b/backend/tests/test_exports.py @@ -0,0 +1,304 @@ +""" +Tests for export service and export endpoints. +""" + +import pytest +import io +import csv +from datetime import datetime +from unittest.mock import MagicMock, patch +from openpyxl import load_workbook + +from backend.services.export_service import ( + InventorySnapshotExporter, + AuditTrailExporter, + get_export_filename, +) +from backend.models import Item, AuditLog, User + + +class TestInventorySnapshotExporter: + """Tests for inventory snapshot export functionality.""" + + @pytest.fixture + def sample_items(self): + """Create sample items for testing.""" + items = [] + for i in range(3): + item = MagicMock(spec=Item) + item.id = i + 1 + item.name = f"Item {i + 1}" + item.part_number = f"PN{i + 1}" + item.barcode = f"BC{i + 1}" + item.category = "Electronics" + item.type = "Component" + item.quantity = float(10 + i) + item.min_quantity = 1.0 + item.description = f"Description {i + 1}" + item.color = "Blue" + item.size = "Medium" + item.connector = "USB" + item.box_label = f"BOX{i + 1}" + item.created_at = datetime(2026, 4, 22, 10, 0, 0) + item.updated_at = datetime(2026, 4, 22, 15, 0, 0) + items.append(item) + return items + + def test_csv_export_basic(self, sample_items): + """Test CSV export with sample items.""" + csv_content = InventorySnapshotExporter.to_csv(sample_items, "2026-04-22") + + # Verify CSV is not empty + assert csv_content + assert "Item 1" in csv_content + assert "PN1" in csv_content + + # Parse CSV and verify structure + reader = csv.DictReader(io.StringIO(csv_content)) + rows = list(reader) + assert len(rows) == 3 + assert rows[0]["Name"] == "Item 1" + assert rows[0]["Part Number"] == "PN1" + + def test_csv_export_headers(self, sample_items): + """Test CSV export contains all expected headers.""" + csv_content = InventorySnapshotExporter.to_csv(sample_items, "2026-04-22") + + expected_headers = [ + "ID", "Name", "Part Number", "Barcode", "Category", + "Type", "Quantity", "Min Quantity", "Description", + "Color", "Size", "Connector", "Box Label", "Created", "Modified" + ] + + reader = csv.DictReader(io.StringIO(csv_content)) + assert list(reader.fieldnames) == expected_headers + + def test_csv_export_empty(self): + """Test CSV export with empty item list.""" + csv_content = InventorySnapshotExporter.to_csv([], "2026-04-22") + + # Should still have headers + assert "ID" in csv_content + assert "Name" in csv_content + + def test_excel_export_basic(self, sample_items): + """Test Excel export with sample items.""" + excel_content = InventorySnapshotExporter.to_excel(sample_items, "2026-04-22") + + # Verify it's bytes + assert isinstance(excel_content, bytes) + assert len(excel_content) > 0 + + # Load and verify Excel structure + wb = load_workbook(io.BytesIO(excel_content)) + ws = wb.active + assert ws.title == "Inventory Snapshot" + assert ws["A1"].value == "Inventory Snapshot - 2026-04-22" + + def test_excel_export_headers(self, sample_items): + """Test Excel export contains all headers.""" + excel_content = InventorySnapshotExporter.to_excel(sample_items, "2026-04-22") + wb = load_workbook(io.BytesIO(excel_content)) + ws = wb.active + + # Headers are in row 2 (row 1 is title) + headers = [cell.value for cell in ws[2]] + expected_headers = [ + "ID", "Name", "Part Number", "Barcode", "Category", + "Type", "Quantity", "Min Quantity", "Description", + "Color", "Size", "Connector", "Box Label", "Created", "Modified" + ] + assert headers == expected_headers + + def test_excel_export_data(self, sample_items): + """Test Excel export contains correct data.""" + excel_content = InventorySnapshotExporter.to_excel(sample_items, "2026-04-22") + wb = load_workbook(io.BytesIO(excel_content)) + ws = wb.active + + # Data starts at row 3 (row 1 = title, row 2 = headers) + first_data_row = list(ws[3]) + assert first_data_row[0].value == 1 # ID + assert first_data_row[1].value == "Item 1" # Name + + def test_excel_export_empty(self): + """Test Excel export with empty item list.""" + excel_content = InventorySnapshotExporter.to_excel([], "2026-04-22") + wb = load_workbook(io.BytesIO(excel_content)) + ws = wb.active + + # Should have title and headers + assert ws["A1"].value == "Inventory Snapshot - 2026-04-22" + assert ws["A2"].value == "ID" + + +class TestAuditTrailExporter: + """Tests for audit trail export functionality.""" + + @pytest.fixture + def sample_logs(self): + """Create sample audit logs for testing.""" + logs = [] + for i in range(3): + user = MagicMock(spec=User) + user.username = f"user{i + 1}" + + log = MagicMock(spec=AuditLog) + log.id = i + 1 + log.timestamp = datetime(2026, 4, 22, 10 + i, 0, 0) + log.user = user + log.action = "CHECK_IN" if i % 2 == 0 else "CHECK_OUT" + log.target_item_id = i + 100 + log.target_item_name = f"Item {i + 1}" + log.target_item_pn = f"PN{i + 1}" + log.target_item_barcode = f"BC{i + 1}" + log.quantity_change = float(i + 1) + log.details = f"Action details {i + 1}" + logs.append(log) + return logs + + def test_csv_export_basic(self, sample_logs): + """Test audit trail CSV export with sample logs.""" + csv_content = AuditTrailExporter.to_csv(sample_logs, "2026-04-22") + + # Verify CSV is not empty + assert csv_content + assert "CHECK_IN" in csv_content or "CHECK_OUT" in csv_content + + # Parse CSV and verify structure + reader = csv.DictReader(io.StringIO(csv_content)) + rows = list(reader) + assert len(rows) == 3 + + def test_csv_export_headers(self, sample_logs): + """Test audit trail CSV export headers.""" + csv_content = AuditTrailExporter.to_csv(sample_logs, "2026-04-22") + + expected_headers = [ + "ID", "Timestamp", "User", "Action", "Item ID", + "Item Name", "Item Part Number", "Item Barcode", + "Quantity Change", "Details" + ] + + reader = csv.DictReader(io.StringIO(csv_content)) + assert list(reader.fieldnames) == expected_headers + + def test_excel_export_basic(self, sample_logs): + """Test audit trail Excel export.""" + excel_content = AuditTrailExporter.to_excel(sample_logs, "2026-04-22") + + assert isinstance(excel_content, bytes) + assert len(excel_content) > 0 + + wb = load_workbook(io.BytesIO(excel_content)) + ws = wb.active + assert ws.title == "Audit Trail" + assert "Audit Trail - 2026-04-22" in ws["A1"].value + + def test_excel_export_data(self, sample_logs): + """Test audit trail Excel export contains correct data.""" + excel_content = AuditTrailExporter.to_excel(sample_logs, "2026-04-22") + wb = load_workbook(io.BytesIO(excel_content)) + ws = wb.active + + # Data starts at row 3 + first_data_row = list(ws[3]) + assert first_data_row[0].value == 1 # ID + assert "user1" in str(first_data_row[2].value) or first_data_row[2].value == "user1" # User + + +class TestFilenameGeneration: + """Tests for export filename generation.""" + + def test_csv_filename(self): + """Test CSV filename generation.""" + filename = get_export_filename("inventory_snapshot", "csv", "2026-04-22") + assert filename == "inventory_snapshot_2026-04-22.csv" + + def test_xlsx_filename(self): + """Test Excel filename generation.""" + filename = get_export_filename("audit_trail", "xlsx", "2026-04-22") + assert filename == "audit_trail_2026-04-22.xlsx" + + def test_filename_with_different_dates(self): + """Test filename with different date formats.""" + filename = get_export_filename("inventory_snapshot", "csv", "2026-01-15") + assert "2026-01-15" in filename + + +class TestExportEndpoints: + """Tests for export API endpoints.""" + + @pytest.mark.asyncio + async def test_export_inventory_snapshot_csv(self, client, admin_user, db): + """Test inventory snapshot CSV export endpoint.""" + response = client.post( + "/api/admin/exports/inventory-snapshot?format=csv", + headers={"Authorization": f"Bearer {admin_user.token}"} + ) + + assert response.status_code == 200 + assert "text/csv" in response.headers.get("content-type", "") + assert "inventory_snapshot" in response.headers.get("content-disposition", "") + + @pytest.mark.asyncio + async def test_export_inventory_snapshot_xlsx(self, client, admin_user, db): + """Test inventory snapshot Excel export endpoint.""" + response = client.post( + "/api/admin/exports/inventory-snapshot?format=xlsx", + headers={"Authorization": f"Bearer {admin_user.token}"} + ) + + assert response.status_code == 200 + assert "spreadsheetml" in response.headers.get("content-type", "") + + @pytest.mark.asyncio + async def test_export_audit_trail_csv(self, client, admin_user, db): + """Test audit trail CSV export endpoint.""" + response = client.post( + "/api/admin/exports/audit-trail?format=csv", + headers={"Authorization": f"Bearer {admin_user.token}"} + ) + + assert response.status_code == 200 + assert "text/csv" in response.headers.get("content-type", "") + assert "audit_trail" in response.headers.get("content-disposition", "") + + @pytest.mark.asyncio + async def test_export_audit_trail_xlsx(self, client, admin_user, db): + """Test audit trail Excel export endpoint.""" + response = client.post( + "/api/admin/exports/audit-trail?format=xlsx", + headers={"Authorization": f"Bearer {admin_user.token}"} + ) + + assert response.status_code == 200 + assert "spreadsheetml" in response.headers.get("content-type", "") + + @pytest.mark.asyncio + async def test_export_invalid_format(self, client, admin_user, db): + """Test export with invalid format parameter.""" + response = client.post( + "/api/admin/exports/inventory-snapshot?format=pdf", + headers={"Authorization": f"Bearer {admin_user.token}"} + ) + + assert response.status_code == 400 + assert "Invalid format" in response.json().get("detail", "") + + @pytest.mark.asyncio + async def test_export_unauthorized(self, client, db): + """Test export endpoint without authorization.""" + response = client.post("/api/admin/exports/inventory-snapshot?format=csv") + + assert response.status_code == 403 + + @pytest.mark.asyncio + async def test_export_non_admin(self, client, regular_user, db): + """Test export endpoint with non-admin user.""" + response = client.post( + "/api/admin/exports/inventory-snapshot?format=csv", + headers={"Authorization": f"Bearer {regular_user.token}"} + ) + + assert response.status_code == 403 diff --git a/frontend/tests/admin/exports.test.ts b/frontend/tests/admin/exports.test.ts new file mode 100644 index 00000000..9062de75 --- /dev/null +++ b/frontend/tests/admin/exports.test.ts @@ -0,0 +1,253 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import axios from 'axios'; +import { useExport } from '@/hooks/useExport'; + +// Mock axios +vi.mock('axios'); +const mockedAxios = axios as any; + +describe('useExport Hook', () => { + beforeEach(() => { + vi.clearAllMocks(); + // Mock blob creation + vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:mock-url'); + vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {}); + }); + + it('should initialize with default state', () => { + const { result } = renderHook(() => useExport()); + + expect(result.current.isLoading).toBe(false); + expect(result.current.error).toBe(null); + }); + + describe('exportSnapshot', () => { + it('should export inventory snapshot as CSV', async () => { + const mockBlob = new Blob(['CSV data'], { type: 'text/csv' }); + mockedAxios.post.mockResolvedValueOnce({ + data: mockBlob, + headers: { + 'content-disposition': 'attachment; filename="inventory_snapshot_2026-04-22.csv"' + } + }); + + const { result } = renderHook(() => useExport()); + + await act(async () => { + await result.current.exportSnapshot('csv'); + }); + + expect(mockedAxios.post).toHaveBeenCalledWith( + '/api/admin/exports/inventory-snapshot?format=csv', + {}, + { responseType: 'blob' } + ); + expect(result.current.error).toBeNull(); + }); + + it('should export inventory snapshot as Excel', async () => { + const mockBlob = new Blob(['Excel data'], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }); + mockedAxios.post.mockResolvedValueOnce({ + data: mockBlob, + headers: { + 'content-disposition': 'attachment; filename="inventory_snapshot_2026-04-22.xlsx"' + } + }); + + const { result } = renderHook(() => useExport()); + + await act(async () => { + await result.current.exportSnapshot('xlsx'); + }); + + expect(mockedAxios.post).toHaveBeenCalledWith( + '/api/admin/exports/inventory-snapshot?format=xlsx', + {}, + { responseType: 'blob' } + ); + }); + + it('should set loading state during export', async () => { + const mockBlob = new Blob(['data'], { type: 'text/csv' }); + let resolvePost: (value: any) => void; + const postPromise = new Promise(resolve => { + resolvePost = resolve; + }); + + mockedAxios.post.mockReturnValueOnce(postPromise); + + const { result } = renderHook(() => useExport()); + + const exportPromise = act(async () => { + await result.current.exportSnapshot('csv'); + }); + + // Should be loading + expect(result.current.isLoading).toBe(true); + + // Resolve the post request + resolvePost!({ + data: mockBlob, + headers: { 'content-disposition': 'attachment; filename="test.csv"' } + }); + + await exportPromise; + + // Should not be loading anymore + expect(result.current.isLoading).toBe(false); + }); + + it('should handle export errors', async () => { + const errorMessage = 'Network error'; + mockedAxios.post.mockRejectedValueOnce(new Error(errorMessage)); + + const { result } = renderHook(() => useExport()); + + await act(async () => { + try { + await result.current.exportSnapshot('csv'); + } catch (err) { + // Error is expected + } + }); + + expect(result.current.error).toContain(errorMessage); + }); + }); + + describe('exportAuditTrail', () => { + it('should export audit trail as CSV', async () => { + const mockBlob = new Blob(['Audit CSV'], { type: 'text/csv' }); + mockedAxios.post.mockResolvedValueOnce({ + data: mockBlob, + headers: { + 'content-disposition': 'attachment; filename="audit_trail_2026-04-22.csv"' + } + }); + + const { result } = renderHook(() => useExport()); + + await act(async () => { + await result.current.exportAuditTrail('csv'); + }); + + expect(mockedAxios.post).toHaveBeenCalledWith( + '/api/admin/exports/audit-trail?format=csv', + {}, + { responseType: 'blob' } + ); + }); + + it('should export audit trail as Excel', async () => { + const mockBlob = new Blob(['Audit Excel'], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }); + mockedAxios.post.mockResolvedValueOnce({ + data: mockBlob, + headers: { + 'content-disposition': 'attachment; filename="audit_trail_2026-04-22.xlsx"' + } + }); + + const { result } = renderHook(() => useExport()); + + await act(async () => { + await result.current.exportAuditTrail('xlsx'); + }); + + expect(mockedAxios.post).toHaveBeenCalledWith( + '/api/admin/exports/audit-trail?format=xlsx', + {}, + { responseType: 'blob' } + ); + }); + + it('should handle audit trail export errors', async () => { + const errorMessage = 'Export failed'; + mockedAxios.post.mockRejectedValueOnce(new Error(errorMessage)); + + const { result } = renderHook(() => useExport()); + + await act(async () => { + try { + await result.current.exportAuditTrail('csv'); + } catch (err) { + // Error is expected + } + }); + + expect(result.current.error).toContain(errorMessage); + }); + }); + + describe('Filename extraction', () => { + it('should extract filename from Content-Disposition header', async () => { + const mockBlob = new Blob(['data'], { type: 'text/csv' }); + const filename = 'inventory_snapshot_2026-04-22.csv'; + + mockedAxios.post.mockResolvedValueOnce({ + data: mockBlob, + headers: { + 'content-disposition': `attachment; filename="${filename}"` + } + }); + + const { result } = renderHook(() => useExport()); + + await act(async () => { + await result.current.exportSnapshot('csv'); + }); + + // File should be created with correct filename + const link = document.createElement('a'); + expect(link.download).toBe(''); + }); + + it('should use default filename if header not provided', async () => { + const mockBlob = new Blob(['data'], { type: 'text/csv' }); + + mockedAxios.post.mockResolvedValueOnce({ + data: mockBlob, + headers: {} + }); + + const { result } = renderHook(() => useExport()); + + await act(async () => { + await result.current.exportSnapshot('csv'); + }); + + expect(result.current.error).toBeNull(); + }); + }); + + describe('Multiple concurrent exports', () => { + it('should prevent concurrent exports with isLoading flag', async () => { + const mockBlob = new Blob(['data'], { type: 'text/csv' }); + let resolvePost: (value: any) => void; + const postPromise = new Promise(resolve => { + resolvePost = resolve; + }); + + mockedAxios.post.mockReturnValueOnce(postPromise); + + const { result } = renderHook(() => useExport()); + + const exportPromise = act(async () => { + await result.current.exportSnapshot('csv'); + }); + + // Should be loading + expect(result.current.isLoading).toBe(true); + + // Resolve the request + resolvePost!({ + data: mockBlob, + headers: { 'content-disposition': 'attachment; filename="test.csv"' } + }); + + await exportPromise; + + expect(result.current.isLoading).toBe(false); + }); + }); +});