2026-08-13 22:43:33 +02:00
|
|
|
"""Import/export tests — helpers, service partial-failure, preview/validate routes, exports."""
|
2026-06-29 00:44:34 +02:00
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
2026-08-13 22:43:33 +02:00
|
|
|
import json
|
2026-06-29 00:44:34 +02:00
|
|
|
import pytest
|
|
|
|
|
from httpx import AsyncClient
|
|
|
|
|
|
2026-08-13 22:43:33 +02:00
|
|
|
from app.services.import_export_helpers import (
|
|
|
|
|
parse_csv,
|
|
|
|
|
parse_json,
|
|
|
|
|
write_csv,
|
|
|
|
|
write_json,
|
|
|
|
|
map_fields,
|
|
|
|
|
validate_row,
|
|
|
|
|
build_error_report,
|
|
|
|
|
build_import_result,
|
|
|
|
|
suggest_mapping,
|
|
|
|
|
detect_format,
|
|
|
|
|
)
|
|
|
|
|
from app.services import import_export_service
|
2026-06-29 17:43:56 +02:00
|
|
|
from tests.conftest import ORIGIN_HEADER, login_client, seed_tenant_and_users
|
2026-06-29 00:44:34 +02:00
|
|
|
|
2026-08-13 22:43:33 +02:00
|
|
|
# ─── Test data ───────────────────────────────────────────────────────────────
|
|
|
|
|
|
2026-08-12 20:47:43 +02:00
|
|
|
CSV_COMPANIES = """name,industry,phone,email,website
|
|
|
|
|
ImportCorp,IT,123456,import@example.com,https://import.example
|
|
|
|
|
TechImport,Finance,654321,tech@example.com,https://tech.example
|
2026-06-29 00:44:34 +02:00
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
CSV_COMPANIES_INVALID = """name,industry
|
|
|
|
|
,IT
|
|
|
|
|
ValidCorp,Finance
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
CSV_CONTACTS = """first_name,last_name,email,phone,mobile,position,department
|
|
|
|
|
Alice,Wonderland,alice@example.com,123,456,Manager,Sales
|
|
|
|
|
Bob,Builder,bob@example.com,789,012,Developer,Tech
|
|
|
|
|
"""
|
|
|
|
|
|
2026-08-13 22:43:33 +02:00
|
|
|
CSV_CONTACTS_PARTIAL = """firstname,surname,email,phone
|
|
|
|
|
Alice,Wonderland,alice@example.com,123
|
|
|
|
|
,,bad@example.com,456
|
|
|
|
|
Bob,Builder,bob@example.com,789
|
|
|
|
|
,,not-an-email,999
|
|
|
|
|
Charlie,Chaplin,charlie@example.com,555
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
CSV_COMPANIES_PARTIAL = """name,email,phone
|
|
|
|
|
ValidCorp1,corp1@example.com,123
|
|
|
|
|
,corp2@example.com,456
|
|
|
|
|
ValidCorp2,corp2@example.com,789
|
|
|
|
|
,bad-email,999
|
|
|
|
|
ValidCorp3,corp3@example.com,111
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
JSON_CONTACTS = json.dumps([
|
|
|
|
|
{"firstname": "Json", "surname": "User", "email": "json@example.com"},
|
|
|
|
|
{"firstname": "Another", "surname": "Person", "email": "another@example.com"},
|
|
|
|
|
])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ─── Helper unit tests ───────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
class TestHelpers:
|
|
|
|
|
"""Unit tests for import_export_helpers functions."""
|
|
|
|
|
|
|
|
|
|
def test_parse_csv_basic(self):
|
|
|
|
|
"""parse_csv parses CSV bytes into list of dicts."""
|
|
|
|
|
content = b"name,email\nAlice,alice@example.com\nBob,bob@example.com\n"
|
|
|
|
|
rows = parse_csv(content)
|
|
|
|
|
assert len(rows) == 2
|
|
|
|
|
assert rows[0]["name"] == "Alice"
|
|
|
|
|
assert rows[0]["email"] == "alice@example.com"
|
|
|
|
|
assert rows[1]["name"] == "Bob"
|
|
|
|
|
|
|
|
|
|
def test_parse_csv_with_bom(self):
|
|
|
|
|
"""parse_csv handles UTF-8 BOM."""
|
|
|
|
|
content = b"\xef\xbb\xbfname,email\nAlice,alice@example.com\n"
|
|
|
|
|
rows = parse_csv(content)
|
|
|
|
|
assert len(rows) == 1
|
|
|
|
|
assert rows[0]["name"] == "Alice"
|
|
|
|
|
|
|
|
|
|
def test_parse_json_array(self):
|
|
|
|
|
"""parse_json parses JSON array into list of dicts."""
|
|
|
|
|
content = json.dumps([{"a": "1"}, {"a": "2"}]).encode()
|
|
|
|
|
rows = parse_json(content)
|
|
|
|
|
assert len(rows) == 2
|
|
|
|
|
assert rows[0]["a"] == "1"
|
|
|
|
|
assert rows[1]["a"] == "2"
|
|
|
|
|
|
|
|
|
|
def test_parse_json_single_object(self):
|
|
|
|
|
"""parse_json wraps single JSON object into list."""
|
|
|
|
|
content = json.dumps({"a": "1"}).encode()
|
|
|
|
|
rows = parse_json(content)
|
|
|
|
|
assert len(rows) == 1
|
|
|
|
|
assert rows[0]["a"] == "1"
|
|
|
|
|
|
|
|
|
|
def test_write_csv_basic(self):
|
|
|
|
|
"""write_csv produces correct CSV bytes."""
|
|
|
|
|
rows = [{"name": "Alice", "email": "alice@example.com"}]
|
|
|
|
|
result = write_csv(rows, ["name", "email"])
|
|
|
|
|
text = result.decode("utf-8")
|
|
|
|
|
assert "name,email" in text
|
|
|
|
|
assert "Alice,alice@example.com" in text
|
|
|
|
|
|
|
|
|
|
def test_write_csv_missing_fields(self):
|
|
|
|
|
"""write_csv fills missing fields with empty string."""
|
|
|
|
|
rows = [{"name": "Alice"}]
|
|
|
|
|
result = write_csv(rows, ["name", "email", "phone"])
|
|
|
|
|
text = result.decode("utf-8")
|
|
|
|
|
assert "Alice," in text
|
|
|
|
|
|
|
|
|
|
def test_write_json_basic(self):
|
|
|
|
|
"""write_json produces correct JSON bytes."""
|
|
|
|
|
rows = [{"name": "Alice"}, {"name": "Bob"}]
|
|
|
|
|
result = write_json(rows)
|
|
|
|
|
data = json.loads(result)
|
|
|
|
|
assert len(data) == 2
|
|
|
|
|
assert data[0]["name"] == "Alice"
|
|
|
|
|
assert data[1]["name"] == "Bob"
|
|
|
|
|
|
|
|
|
|
def test_map_fields_basic(self):
|
|
|
|
|
"""map_fields maps source columns to target fields."""
|
|
|
|
|
row = {"first_name": "Alice", "last_name": "Wonder", "email": "alice@example.com"}
|
|
|
|
|
mapping = {"first_name": "firstname", "last_name": "surname", "email": "email"}
|
|
|
|
|
result = map_fields(row, mapping)
|
|
|
|
|
assert result["firstname"] == "Alice"
|
|
|
|
|
assert result["surname"] == "Wonder"
|
|
|
|
|
assert result["email"] == "alice@example.com"
|
|
|
|
|
|
|
|
|
|
def test_map_fields_skip_missing(self):
|
|
|
|
|
"""map_fields skips source columns not in row."""
|
|
|
|
|
row = {"first_name": "Alice"}
|
|
|
|
|
mapping = {"first_name": "firstname", "last_name": "surname"}
|
|
|
|
|
result = map_fields(row, mapping)
|
|
|
|
|
assert "firstname" in result
|
|
|
|
|
assert "surname" not in result
|
|
|
|
|
|
|
|
|
|
def test_validate_row_required(self):
|
|
|
|
|
"""validate_row detects missing required fields."""
|
|
|
|
|
row = {"name": "", "email": "test@example.com"}
|
|
|
|
|
errors = validate_row(row, ["name"])
|
|
|
|
|
assert len(errors) == 1
|
|
|
|
|
assert "name" in errors[0]
|
|
|
|
|
|
|
|
|
|
def test_validate_row_valid(self):
|
|
|
|
|
"""validate_row returns empty list for valid row."""
|
|
|
|
|
row = {"name": "Alice", "email": "alice@example.com"}
|
|
|
|
|
errors = validate_row(row, ["name"], {"email": {"type": "email"}})
|
|
|
|
|
assert len(errors) == 0
|
|
|
|
|
|
|
|
|
|
def test_validate_row_email_validator(self):
|
|
|
|
|
"""validate_row detects invalid email."""
|
|
|
|
|
row = {"name": "Alice", "email": "not-an-email"}
|
|
|
|
|
errors = validate_row(row, ["name"], {"email": {"type": "email"}})
|
|
|
|
|
assert len(errors) == 1
|
|
|
|
|
assert "email" in errors[0]
|
|
|
|
|
|
|
|
|
|
def test_validate_row_url_validator(self):
|
|
|
|
|
"""validate_row detects invalid URL."""
|
|
|
|
|
row = {"website": "not-a-url"}
|
|
|
|
|
errors = validate_row(row, [], {"website": {"type": "url"}})
|
|
|
|
|
assert len(errors) == 1
|
|
|
|
|
assert "URL" in errors[0]
|
|
|
|
|
|
|
|
|
|
def test_validate_row_max_length(self):
|
|
|
|
|
"""validate_row enforces max_length."""
|
|
|
|
|
row = {"name": "A" * 100}
|
|
|
|
|
errors = validate_row(row, [], {"name": {"max_length": 50}})
|
|
|
|
|
assert len(errors) == 1
|
|
|
|
|
assert "max length" in errors[0]
|
|
|
|
|
|
|
|
|
|
def test_build_error_report_structure(self):
|
|
|
|
|
"""build_error_report returns correct structure."""
|
|
|
|
|
errors = [
|
|
|
|
|
{"row": 1, "field": "name", "message": "Missing required"},
|
|
|
|
|
{"row": 2, "field": "email", "message": "Invalid email"},
|
|
|
|
|
]
|
|
|
|
|
report = build_error_report(errors)
|
|
|
|
|
assert report["total_errors"] == 2
|
|
|
|
|
assert len(report["errors"]) == 2
|
|
|
|
|
assert report["errors"][0]["row"] == 1
|
|
|
|
|
assert report["errors"][0]["message"] == "Missing required"
|
|
|
|
|
|
|
|
|
|
def test_build_error_report_empty(self):
|
|
|
|
|
"""build_error_report handles empty list."""
|
|
|
|
|
report = build_error_report([])
|
|
|
|
|
assert report["total_errors"] == 0
|
|
|
|
|
assert report["errors"] == []
|
|
|
|
|
|
|
|
|
|
def test_build_import_result_success(self):
|
|
|
|
|
"""build_import_result returns 'success' status when all succeed."""
|
|
|
|
|
result = build_import_result(total=10, succeeded=10, failed=0)
|
|
|
|
|
assert result["status"] == "success"
|
|
|
|
|
assert result["succeeded"] == 10
|
|
|
|
|
assert result["failed"] == 0
|
|
|
|
|
|
|
|
|
|
def test_build_import_result_partial(self):
|
|
|
|
|
"""build_import_result returns 'partial_success' when some fail."""
|
|
|
|
|
result = build_import_result(total=10, succeeded=7, failed=3)
|
|
|
|
|
assert result["status"] == "partial_success"
|
|
|
|
|
|
|
|
|
|
def test_build_import_result_all_failed(self):
|
|
|
|
|
"""build_import_result returns 'failed' when all fail."""
|
|
|
|
|
result = build_import_result(total=10, succeeded=0, failed=10)
|
|
|
|
|
assert result["status"] == "failed"
|
|
|
|
|
|
|
|
|
|
def test_suggest_mapping_exact_match(self):
|
|
|
|
|
"""suggest_mapping matches exact column names."""
|
|
|
|
|
mapping = suggest_mapping(["firstname", "surname", "email"], ["firstname", "surname", "email"])
|
|
|
|
|
assert mapping["firstname"] == "firstname"
|
|
|
|
|
assert mapping["surname"] == "surname"
|
|
|
|
|
assert mapping["email"] == "email"
|
|
|
|
|
|
|
|
|
|
def test_suggest_mapping_alias(self):
|
|
|
|
|
"""suggest_mapping matches common aliases."""
|
|
|
|
|
mapping = suggest_mapping(["first_name", "last_name"], ["firstname", "surname"])
|
|
|
|
|
assert mapping["first_name"] == "firstname"
|
|
|
|
|
assert mapping["last_name"] == "surname"
|
|
|
|
|
|
|
|
|
|
def test_detect_format_csv(self):
|
|
|
|
|
"""detect_format identifies CSV from filename."""
|
|
|
|
|
assert detect_format("data.csv", b"name,email\n") == "csv"
|
|
|
|
|
|
|
|
|
|
def test_detect_format_json(self):
|
|
|
|
|
"""detect_format identifies JSON from filename."""
|
|
|
|
|
assert detect_format("data.json", b"[]") == "json"
|
|
|
|
|
|
|
|
|
|
def test_detect_format_xlsx(self):
|
|
|
|
|
"""detect_format identifies XLSX from filename."""
|
|
|
|
|
assert detect_format("data.xlsx", b"PK\x03\x04") == "xlsx"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ─── Service partial-failure tests ───────────────────────────────────────────
|
2026-06-29 00:44:34 +02:00
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
2026-08-13 22:43:33 +02:00
|
|
|
class TestImportCompaniesPartialFailure:
|
|
|
|
|
"""Import companies with partial-failure semantics."""
|
2026-06-29 00:44:34 +02:00
|
|
|
|
2026-08-13 22:43:33 +02:00
|
|
|
async def test_import_companies_partial_failure(self, client: AsyncClient, db_session):
|
|
|
|
|
"""Import companies: 3 success, 2 failed (empty name + invalid email)."""
|
|
|
|
|
await seed_tenant_and_users(db_session)
|
|
|
|
|
await login_client(client, "admin@tenanta.com")
|
|
|
|
|
files = {"file": ("companies.csv", CSV_COMPANIES_PARTIAL.encode(), "text/csv")}
|
|
|
|
|
data = {"entity_type": "companies"}
|
|
|
|
|
resp = await client.post(
|
|
|
|
|
"/api/v1/import",
|
|
|
|
|
files=files,
|
|
|
|
|
data=data,
|
|
|
|
|
headers=ORIGIN_HEADER,
|
|
|
|
|
)
|
|
|
|
|
assert resp.status_code == 200
|
|
|
|
|
result = resp.json()
|
|
|
|
|
assert result["total"] == 5
|
|
|
|
|
assert result["succeeded"] == 3
|
|
|
|
|
assert result["failed"] == 2
|
|
|
|
|
assert result["status"] == "partial_success"
|
|
|
|
|
assert len(result["created"]) == 3
|
|
|
|
|
# 2 failed rows, but 3 total error messages (row 4 has 2 errors)
|
|
|
|
|
assert result["error_report"]["total_errors"] == 3
|
|
|
|
|
|
|
|
|
|
async def test_import_companies_all_valid(self, client: AsyncClient, db_session):
|
|
|
|
|
"""Import companies: all rows valid."""
|
2026-06-29 00:44:34 +02:00
|
|
|
await seed_tenant_and_users(db_session)
|
|
|
|
|
await login_client(client, "admin@tenanta.com")
|
|
|
|
|
files = {"file": ("companies.csv", CSV_COMPANIES.encode(), "text/csv")}
|
|
|
|
|
data = {"entity_type": "companies"}
|
|
|
|
|
resp = await client.post(
|
|
|
|
|
"/api/v1/import",
|
|
|
|
|
files=files,
|
|
|
|
|
data=data,
|
|
|
|
|
headers=ORIGIN_HEADER,
|
|
|
|
|
)
|
|
|
|
|
assert resp.status_code == 200
|
|
|
|
|
result = resp.json()
|
|
|
|
|
assert result["total"] == 2
|
2026-08-13 22:43:33 +02:00
|
|
|
assert result["succeeded"] == 2
|
|
|
|
|
assert result["failed"] == 0
|
|
|
|
|
assert result["status"] == "success"
|
2026-06-29 00:44:34 +02:00
|
|
|
|
2026-08-13 22:43:33 +02:00
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
class TestImportContactsPartialFailure:
|
|
|
|
|
"""Import contacts with partial-failure semantics."""
|
|
|
|
|
|
|
|
|
|
async def test_import_contacts_partial_failure(self, client: AsyncClient, db_session):
|
|
|
|
|
"""Import contacts: 3 success, 2 failed (empty names + invalid email)."""
|
2026-06-29 00:44:34 +02:00
|
|
|
await seed_tenant_and_users(db_session)
|
|
|
|
|
await login_client(client, "admin@tenanta.com")
|
2026-08-13 22:43:33 +02:00
|
|
|
files = {"file": ("contacts.csv", CSV_CONTACTS_PARTIAL.encode(), "text/csv")}
|
|
|
|
|
data = {"entity_type": "contacts"}
|
|
|
|
|
resp = await client.post(
|
|
|
|
|
"/api/v1/import",
|
|
|
|
|
files=files,
|
|
|
|
|
data=data,
|
|
|
|
|
headers=ORIGIN_HEADER,
|
|
|
|
|
)
|
|
|
|
|
assert resp.status_code == 200
|
|
|
|
|
result = resp.json()
|
|
|
|
|
assert result["total"] == 5
|
|
|
|
|
assert result["succeeded"] == 3
|
|
|
|
|
assert result["failed"] == 2
|
|
|
|
|
assert result["status"] == "partial_success"
|
|
|
|
|
assert len(result["created"]) == 3
|
|
|
|
|
|
|
|
|
|
async def test_import_contacts_all_valid(self, client: AsyncClient, db_session):
|
|
|
|
|
"""Import contacts: all rows valid."""
|
|
|
|
|
await seed_tenant_and_users(db_session)
|
|
|
|
|
await login_client(client, "admin@tenanta.com")
|
|
|
|
|
files = {"file": ("contacts.csv", CSV_CONTACTS.encode(), "text/csv")}
|
|
|
|
|
data = {"entity_type": "contacts"}
|
2026-06-29 00:44:34 +02:00
|
|
|
resp = await client.post(
|
|
|
|
|
"/api/v1/import",
|
|
|
|
|
files=files,
|
|
|
|
|
data=data,
|
|
|
|
|
headers=ORIGIN_HEADER,
|
|
|
|
|
)
|
|
|
|
|
assert resp.status_code == 200
|
|
|
|
|
result = resp.json()
|
|
|
|
|
assert result["total"] == 2
|
2026-08-13 22:43:33 +02:00
|
|
|
assert result["succeeded"] == 2
|
|
|
|
|
assert result["status"] == "success"
|
|
|
|
|
|
2026-06-29 00:44:34 +02:00
|
|
|
|
2026-08-13 22:43:33 +02:00
|
|
|
# ─── Preview & Validate route tests ──────────────────────────────────────────
|
2026-06-29 00:44:34 +02:00
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
class TestImportPreview:
|
2026-08-13 22:43:33 +02:00
|
|
|
"""Preview endpoint returns first 10 rows + mapping suggestion."""
|
2026-06-29 00:44:34 +02:00
|
|
|
|
2026-08-13 22:43:33 +02:00
|
|
|
async def test_preview_returns_columns_and_rows(self, client: AsyncClient, db_session):
|
|
|
|
|
"""POST /import/preview returns columns, preview_rows, mapping_suggestion."""
|
2026-06-29 00:44:34 +02:00
|
|
|
await seed_tenant_and_users(db_session)
|
|
|
|
|
await login_client(client, "admin@tenanta.com")
|
|
|
|
|
files = {"file": ("companies.csv", CSV_COMPANIES.encode(), "text/csv")}
|
|
|
|
|
data = {"entity_type": "companies"}
|
|
|
|
|
resp = await client.post(
|
|
|
|
|
"/api/v1/import/preview",
|
|
|
|
|
files=files,
|
|
|
|
|
data=data,
|
|
|
|
|
headers=ORIGIN_HEADER,
|
|
|
|
|
)
|
|
|
|
|
assert resp.status_code == 200
|
|
|
|
|
result = resp.json()
|
2026-08-13 22:43:33 +02:00
|
|
|
assert result["total_rows"] == 2
|
|
|
|
|
assert "name" in result["columns"]
|
|
|
|
|
assert "email" in result["columns"]
|
|
|
|
|
assert len(result["preview_rows"]) == 2
|
|
|
|
|
assert "mapping_suggestion" in result
|
|
|
|
|
assert "target_fields" in result
|
2026-06-29 00:44:34 +02:00
|
|
|
|
2026-08-13 22:43:33 +02:00
|
|
|
async def test_preview_contacts_mapping_suggestion(self, client: AsyncClient, db_session):
|
|
|
|
|
"""Preview contacts: mapping suggestion maps first_name→firstname."""
|
|
|
|
|
await seed_tenant_and_users(db_session)
|
|
|
|
|
await login_client(client, "admin@tenanta.com")
|
|
|
|
|
files = {"file": ("contacts.csv", CSV_CONTACTS.encode(), "text/csv")}
|
|
|
|
|
data = {"entity_type": "contacts"}
|
|
|
|
|
resp = await client.post(
|
|
|
|
|
"/api/v1/import/preview",
|
|
|
|
|
files=files,
|
|
|
|
|
data=data,
|
|
|
|
|
headers=ORIGIN_HEADER,
|
|
|
|
|
)
|
|
|
|
|
assert resp.status_code == 200
|
|
|
|
|
result = resp.json()
|
|
|
|
|
mapping = result["mapping_suggestion"]
|
|
|
|
|
# first_name should map to firstname
|
|
|
|
|
assert mapping.get("first_name") == "firstname"
|
|
|
|
|
# last_name should map to surname
|
|
|
|
|
assert mapping.get("last_name") == "surname"
|
|
|
|
|
|
|
|
|
|
async def test_preview_no_db_changes(self, client: AsyncClient, db_session):
|
|
|
|
|
"""Preview does not create any records."""
|
|
|
|
|
await seed_tenant_and_users(db_session)
|
|
|
|
|
await login_client(client, "admin@tenanta.com")
|
|
|
|
|
files = {"file": ("companies.csv", CSV_COMPANIES.encode(), "text/csv")}
|
|
|
|
|
data = {"entity_type": "companies"}
|
|
|
|
|
resp = await client.post(
|
|
|
|
|
"/api/v1/import/preview",
|
|
|
|
|
files=files,
|
|
|
|
|
data=data,
|
|
|
|
|
headers=ORIGIN_HEADER,
|
|
|
|
|
)
|
|
|
|
|
assert resp.status_code == 200
|
|
|
|
|
# Verify no companies were created
|
2026-06-29 00:44:34 +02:00
|
|
|
list_resp = await client.get("/api/v1/companies", headers=ORIGIN_HEADER)
|
|
|
|
|
names = [item["name"] for item in list_resp.json()["items"]]
|
|
|
|
|
assert "ImportCorp" not in names
|
|
|
|
|
assert "TechImport" not in names
|
|
|
|
|
|
2026-08-13 22:43:33 +02:00
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
class TestImportValidate:
|
|
|
|
|
"""Validate endpoint returns error report without importing."""
|
|
|
|
|
|
|
|
|
|
async def test_validate_returns_error_report(self, client: AsyncClient, db_session):
|
|
|
|
|
"""POST /import/validate returns error report for invalid rows."""
|
|
|
|
|
await seed_tenant_and_users(db_session)
|
|
|
|
|
await login_client(client, "admin@tenanta.com")
|
|
|
|
|
files = {"file": ("companies.csv", CSV_COMPANIES_PARTIAL.encode(), "text/csv")}
|
|
|
|
|
data = {"entity_type": "companies"}
|
|
|
|
|
resp = await client.post(
|
|
|
|
|
"/api/v1/import/validate",
|
|
|
|
|
files=files,
|
|
|
|
|
data=data,
|
|
|
|
|
headers=ORIGIN_HEADER,
|
|
|
|
|
)
|
|
|
|
|
assert resp.status_code == 200
|
|
|
|
|
result = resp.json()
|
|
|
|
|
assert result["total"] == 5
|
|
|
|
|
assert result["succeeded"] == 3
|
|
|
|
|
assert result["failed"] == 2
|
|
|
|
|
assert result["dry_run"] is True
|
|
|
|
|
# 2 failed rows, but 3 total error messages (row 4 has 2 errors)
|
|
|
|
|
assert result["error_report"]["total_errors"] == 3
|
|
|
|
|
|
|
|
|
|
async def test_validate_no_db_changes(self, client: AsyncClient, db_session):
|
|
|
|
|
"""Validate does not create any records."""
|
|
|
|
|
await seed_tenant_and_users(db_session)
|
|
|
|
|
await login_client(client, "admin@tenanta.com")
|
|
|
|
|
files = {"file": ("companies.csv", CSV_COMPANIES.encode(), "text/csv")}
|
|
|
|
|
data = {"entity_type": "companies"}
|
|
|
|
|
resp = await client.post(
|
|
|
|
|
"/api/v1/import/validate",
|
|
|
|
|
files=files,
|
|
|
|
|
data=data,
|
|
|
|
|
headers=ORIGIN_HEADER,
|
|
|
|
|
)
|
|
|
|
|
assert resp.status_code == 200
|
|
|
|
|
list_resp = await client.get("/api/v1/companies", headers=ORIGIN_HEADER)
|
|
|
|
|
names = [item["name"] for item in list_resp.json()["items"]]
|
|
|
|
|
assert "ImportCorp" not in names
|
|
|
|
|
|
|
|
|
|
async def test_validate_with_field_mapping(self, client: AsyncClient, db_session):
|
|
|
|
|
"""Validate with explicit field mapping."""
|
2026-06-29 00:44:34 +02:00
|
|
|
await seed_tenant_and_users(db_session)
|
|
|
|
|
await login_client(client, "admin@tenanta.com")
|
|
|
|
|
files = {"file": ("contacts.csv", CSV_CONTACTS.encode(), "text/csv")}
|
2026-08-13 22:43:33 +02:00
|
|
|
mapping = json.dumps({
|
|
|
|
|
"first_name": "firstname",
|
|
|
|
|
"last_name": "surname",
|
|
|
|
|
"email": "email",
|
|
|
|
|
"phone": "phone",
|
|
|
|
|
"mobile": "mobile",
|
|
|
|
|
"position": "function",
|
|
|
|
|
"department": "department",
|
|
|
|
|
})
|
|
|
|
|
data = {"entity_type": "contacts", "field_mapping": mapping}
|
2026-06-29 00:44:34 +02:00
|
|
|
resp = await client.post(
|
2026-08-13 22:43:33 +02:00
|
|
|
"/api/v1/import/validate",
|
2026-06-29 00:44:34 +02:00
|
|
|
files=files,
|
|
|
|
|
data=data,
|
|
|
|
|
headers=ORIGIN_HEADER,
|
|
|
|
|
)
|
|
|
|
|
assert resp.status_code == 200
|
|
|
|
|
result = resp.json()
|
|
|
|
|
assert result["total"] == 2
|
2026-08-13 22:43:33 +02:00
|
|
|
assert result["succeeded"] == 2
|
|
|
|
|
assert result["failed"] == 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ─── Job status route tests ──────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
class TestImportJobStatus:
|
|
|
|
|
"""Job status endpoint."""
|
|
|
|
|
|
|
|
|
|
async def test_job_status_not_found(self, client: AsyncClient, db_session):
|
|
|
|
|
"""GET /import/status/{nonexistent_id} returns 404."""
|
|
|
|
|
await seed_tenant_and_users(db_session)
|
|
|
|
|
await login_client(client, "admin@tenanta.com")
|
|
|
|
|
resp = await client.get(
|
|
|
|
|
"/api/v1/import/status/nonexistent-job-id",
|
|
|
|
|
headers=ORIGIN_HEADER,
|
|
|
|
|
)
|
|
|
|
|
assert resp.status_code == 404
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ─── Export route tests ──────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
class TestExport:
|
|
|
|
|
"""Export endpoints."""
|
|
|
|
|
|
|
|
|
|
async def test_export_contacts_csv(self, client: AsyncClient, db_session):
|
|
|
|
|
"""GET /export?entity_type=contacts&format=csv returns CSV file."""
|
|
|
|
|
await seed_tenant_and_users(db_session)
|
|
|
|
|
await login_client(client, "admin@tenanta.com")
|
|
|
|
|
resp = await client.get(
|
|
|
|
|
"/api/v1/export?entity_type=contacts&format=csv",
|
|
|
|
|
headers=ORIGIN_HEADER,
|
|
|
|
|
)
|
|
|
|
|
assert resp.status_code == 200
|
|
|
|
|
assert "text/csv" in resp.headers.get("content-type", "")
|
|
|
|
|
|
|
|
|
|
async def test_export_companies_csv(self, client: AsyncClient, db_session):
|
|
|
|
|
"""GET /export?entity_type=companies&format=csv returns CSV file."""
|
|
|
|
|
await seed_tenant_and_users(db_session)
|
|
|
|
|
await login_client(client, "admin@tenanta.com")
|
|
|
|
|
resp = await client.get(
|
|
|
|
|
"/api/v1/export?entity_type=companies&format=csv",
|
|
|
|
|
headers=ORIGIN_HEADER,
|
|
|
|
|
)
|
|
|
|
|
assert resp.status_code == 200
|
|
|
|
|
assert "text/csv" in resp.headers.get("content-type", "")
|
|
|
|
|
|
|
|
|
|
async def test_export_contacts_xlsx(self, client: AsyncClient, db_session):
|
|
|
|
|
"""GET /export?entity_type=contacts&format=xlsx returns XLSX file."""
|
|
|
|
|
await seed_tenant_and_users(db_session)
|
|
|
|
|
await login_client(client, "admin@tenanta.com")
|
|
|
|
|
resp = await client.get(
|
|
|
|
|
"/api/v1/export?entity_type=contacts&format=xlsx",
|
|
|
|
|
headers=ORIGIN_HEADER,
|
|
|
|
|
)
|
|
|
|
|
assert resp.status_code == 200
|
|
|
|
|
ct = resp.headers.get("content-type", "")
|
|
|
|
|
# Could be xlsx or csv fallback if openpyxl missing (but it's installed)
|
|
|
|
|
assert "spreadsheet" in ct or "text/csv" in ct
|
|
|
|
|
|
|
|
|
|
async def test_export_contacts_json(self, client: AsyncClient, db_session):
|
|
|
|
|
"""GET /export?entity_type=contacts&format=json returns JSON file."""
|
|
|
|
|
await seed_tenant_and_users(db_session)
|
|
|
|
|
await login_client(client, "admin@tenanta.com")
|
|
|
|
|
resp = await client.get(
|
|
|
|
|
"/api/v1/export?entity_type=contacts&format=json",
|
|
|
|
|
headers=ORIGIN_HEADER,
|
|
|
|
|
)
|
|
|
|
|
assert resp.status_code == 200
|
|
|
|
|
assert "application/json" in resp.headers.get("content-type", "")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ─── Direct import route tests (legacy compat) ───────────────────────────────
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
class TestImportCompanies:
|
|
|
|
|
"""AC 20: CSV import for companies (backward compat)."""
|
|
|
|
|
|
|
|
|
|
async def test_import_companies_csv_returns_200(self, client: AsyncClient, db_session):
|
|
|
|
|
"""POST /api/v1/import CSV + entity_type=companies -> 200 + result."""
|
|
|
|
|
await seed_tenant_and_users(db_session)
|
|
|
|
|
await login_client(client, "admin@tenanta.com")
|
|
|
|
|
files = {"file": ("companies.csv", CSV_COMPANIES.encode(), "text/csv")}
|
|
|
|
|
data = {"entity_type": "companies"}
|
|
|
|
|
resp = await client.post(
|
|
|
|
|
"/api/v1/import",
|
|
|
|
|
files=files,
|
|
|
|
|
data=data,
|
|
|
|
|
headers=ORIGIN_HEADER,
|
|
|
|
|
)
|
|
|
|
|
assert resp.status_code == 200
|
|
|
|
|
result = resp.json()
|
|
|
|
|
assert result["total"] == 2
|
|
|
|
|
assert result["succeeded"] == 2
|
|
|
|
|
assert result["failed"] == 0
|
|
|
|
|
assert len(result["created"]) == 2
|
|
|
|
|
|
|
|
|
|
async def test_import_companies_with_invalid_rows(self, client: AsyncClient, db_session):
|
|
|
|
|
"""Import CSV with some invalid rows — partial success."""
|
|
|
|
|
await seed_tenant_and_users(db_session)
|
|
|
|
|
await login_client(client, "admin@tenanta.com")
|
|
|
|
|
files = {"file": ("companies.csv", CSV_COMPANIES_INVALID.encode(), "text/csv")}
|
|
|
|
|
data = {"entity_type": "companies"}
|
|
|
|
|
resp = await client.post(
|
|
|
|
|
"/api/v1/import",
|
|
|
|
|
files=files,
|
|
|
|
|
data=data,
|
|
|
|
|
headers=ORIGIN_HEADER,
|
|
|
|
|
)
|
|
|
|
|
assert resp.status_code == 200
|
|
|
|
|
result = resp.json()
|
|
|
|
|
assert result["total"] == 2
|
|
|
|
|
assert result["succeeded"] == 1
|
|
|
|
|
assert result["failed"] == 1
|
|
|
|
|
assert result["status"] == "partial_success"
|
|
|
|
|
assert len(result["created"]) == 1
|
2026-06-29 00:44:34 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
class TestImportContacts:
|
2026-08-13 22:43:33 +02:00
|
|
|
"""Import contacts via CSV (backward compat)."""
|
2026-06-29 00:44:34 +02:00
|
|
|
|
|
|
|
|
async def test_import_contacts_csv_returns_200(self, client: AsyncClient, db_session):
|
|
|
|
|
"""Import contacts via CSV."""
|
|
|
|
|
await seed_tenant_and_users(db_session)
|
|
|
|
|
await login_client(client, "admin@tenanta.com")
|
|
|
|
|
files = {"file": ("contacts.csv", CSV_CONTACTS.encode(), "text/csv")}
|
|
|
|
|
data = {"entity_type": "contacts"}
|
|
|
|
|
resp = await client.post(
|
|
|
|
|
"/api/v1/import",
|
|
|
|
|
files=files,
|
|
|
|
|
data=data,
|
|
|
|
|
headers=ORIGIN_HEADER,
|
|
|
|
|
)
|
|
|
|
|
assert resp.status_code == 200
|
|
|
|
|
result = resp.json()
|
|
|
|
|
assert result["total"] == 2
|
2026-08-13 22:43:33 +02:00
|
|
|
assert result["succeeded"] == 2
|
|
|
|
|
assert result["failed"] == 0
|
2026-06-29 00:44:34 +02:00
|
|
|
assert len(result["created"]) == 2
|
|
|
|
|
# Verify contacts appear in list
|
|
|
|
|
list_resp = await client.get("/api/v1/contacts", headers=ORIGIN_HEADER)
|
|
|
|
|
assert list_resp.json()["total"] >= 2
|
2026-08-13 22:43:33 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# ─── JSON import test ────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
class TestImportJson:
|
|
|
|
|
"""Import from JSON format."""
|
|
|
|
|
|
|
|
|
|
async def test_import_contacts_json(self, client: AsyncClient, db_session):
|
|
|
|
|
"""Import contacts from JSON file."""
|
|
|
|
|
await seed_tenant_and_users(db_session)
|
|
|
|
|
await login_client(client, "admin@tenanta.com")
|
|
|
|
|
files = {"file": ("contacts.json", JSON_CONTACTS.encode(), "application/json")}
|
|
|
|
|
data = {"entity_type": "contacts"}
|
|
|
|
|
resp = await client.post(
|
|
|
|
|
"/api/v1/import",
|
|
|
|
|
files=files,
|
|
|
|
|
data=data,
|
|
|
|
|
headers=ORIGIN_HEADER,
|
|
|
|
|
)
|
|
|
|
|
assert resp.status_code == 200
|
|
|
|
|
result = resp.json()
|
|
|
|
|
assert result["total"] == 2
|
|
|
|
|
assert result["succeeded"] == 2
|
|
|
|
|
assert len(result["created"]) == 2
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ─── Dedicated import routes ─────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
class TestImportContactsRoute:
|
|
|
|
|
"""POST /import/contacts dedicated route."""
|
|
|
|
|
|
|
|
|
|
async def test_import_contacts_route(self, client: AsyncClient, db_session):
|
|
|
|
|
"""POST /import/contacts imports contacts."""
|
|
|
|
|
await seed_tenant_and_users(db_session)
|
|
|
|
|
await login_client(client, "admin@tenanta.com")
|
|
|
|
|
files = {"file": ("contacts.csv", CSV_CONTACTS.encode(), "text/csv")}
|
|
|
|
|
resp = await client.post(
|
|
|
|
|
"/api/v1/import/contacts",
|
|
|
|
|
files=files,
|
|
|
|
|
headers=ORIGIN_HEADER,
|
|
|
|
|
)
|
|
|
|
|
assert resp.status_code == 200
|
|
|
|
|
result = resp.json()
|
|
|
|
|
assert result["total"] == 2
|
|
|
|
|
assert result["succeeded"] == 2
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
class TestImportCompaniesRoute:
|
|
|
|
|
"""POST /import/companies dedicated route."""
|
|
|
|
|
|
|
|
|
|
async def test_import_companies_route(self, client: AsyncClient, db_session):
|
|
|
|
|
"""POST /import/companies imports companies."""
|
|
|
|
|
await seed_tenant_and_users(db_session)
|
|
|
|
|
await login_client(client, "admin@tenanta.com")
|
|
|
|
|
files = {"file": ("companies.csv", CSV_COMPANIES.encode(), "text/csv")}
|
|
|
|
|
resp = await client.post(
|
|
|
|
|
"/api/v1/import/companies",
|
|
|
|
|
files=files,
|
|
|
|
|
headers=ORIGIN_HEADER,
|
|
|
|
|
)
|
|
|
|
|
assert resp.status_code == 200
|
|
|
|
|
result = resp.json()
|
|
|
|
|
assert result["total"] == 2
|
|
|
|
|
assert result["succeeded"] == 2
|