test(5-03-07): add comprehensive export tests for CSV/Excel generation and endpoint authorization

This commit is contained in:
2026-04-22 17:43:23 +03:00
parent 798cf4bfd5
commit fd13f63c28
2 changed files with 557 additions and 0 deletions

View File

@@ -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

View File

@@ -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);
});
});
});