From cf96902b1f40b03684071d65a2f6834b6a15c42f Mon Sep 17 00:00:00 2001
From: Neda Moeini <neda.moeini@geant.org>
Date: Tue, 11 Feb 2025 14:59:40 +0100
Subject: [PATCH 1/9] Add pytest to the project and set up some basic fixtures

---
 pytest.ini       |   4 ++
 requirements.txt |   4 ++
 test/__init__.py |   0
 test/conftest.py | 132 +++++++++++++++++++++++++++++++++++++++++++++++
 test/settings.py |   5 ++
 5 files changed, 145 insertions(+)
 create mode 100644 pytest.ini
 create mode 100644 test/__init__.py
 create mode 100644 test/conftest.py
 create mode 100644 test/settings.py

diff --git a/pytest.ini b/pytest.ini
new file mode 100644
index 0000000..8872a16
--- /dev/null
+++ b/pytest.ini
@@ -0,0 +1,4 @@
+[pytest]
+DJANGO_SETTINGS_MODULE = test.settings
+django_find_project = false
+python_files = tests.py test_*.py *_tests.py
\ No newline at end of file
diff --git a/requirements.txt b/requirements.txt
index 0fb6979..af546b5 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -5,3 +5,7 @@ tox
 sphinx
 sphinx-autodoc-typehints
 mssql-django
+pytest
+pytest-django
+pytest-mock
+faker
diff --git a/test/__init__.py b/test/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/test/conftest.py b/test/conftest.py
new file mode 100644
index 0000000..1d75fae
--- /dev/null
+++ b/test/conftest.py
@@ -0,0 +1,132 @@
+import pytest
+from unittest.mock import MagicMock
+from django.core.files.uploadedfile import SimpleUploadedFile
+from sage_validation.file_validator.models import MeoValidSuppliers, MeoCostCentres, XxData
+from faker import Faker
+
+
+@pytest.fixture
+def sample_input_file():
+    """Creates a sample valid CSV file for testing."""
+    csv_headers = ",".join([
+        "AccountNumber",
+        "CBAccountNumber",
+        "DaysDiscountValid",
+        "DiscountValue",
+        "DiscountPercentage",
+        "DueDate",
+        "GoodsValueInAccountCurrency",
+        "PurControlValueInBaseCurrency",
+        "DocumentToBaseCurrencyRate",
+        "DocumentToAccountCurrencyRate",
+        "PostedDate",
+        "QueryCode",
+        "TransactionReference",
+        "SecondReference",
+        "Source",
+        "SYSTraderTranType",
+        "TransactionDate",
+        "UniqueReferenceNumber",
+        "UserNumber",
+        "TaxValue",
+        "SYSTraderGenerationReasonType",
+        "GoodsValueInBaseCurrency",
+
+        # NominalAnalysis repeating columns (Example: /1 for first occurrence)
+        "NominalAnalysisTransactionValue/1",
+        "NominalAnalysisNominalAccountNumber/1",
+        "NominalAnalysisNominalCostCentre/1",
+        "NominalAnalysisNominalDepartment/1",
+        "NominalAnalysisNominalAnalysisNarrative/1",
+        "NominalAnalysisTransactionAnalysisCode/1",
+
+        # TaxAnalysis repeating columns (Example: /1 for first occurrence)
+        "TaxAnalysisTaxRate/1",
+        "TaxAnalysisGoodsValueBeforeDiscount/1",
+        "TaxAnalysisDiscountValue/1",
+        "TaxAnalysisDiscountPercentage/1",
+        "TaxAnalysisTaxOnGoodsValue/1",
+    ])
+
+    csv_content = ",".join([
+        "12345",  # AccountNumber
+        "54321",  # CBAccountNumber
+        "30",  # DaysDiscountValid
+        "10.5",  # DiscountValue
+        "5.0",  # DiscountPercentage
+        "2024-02-10",  # DueDate
+        "1000",  # GoodsValueInAccountCurrency
+        "950",  # PurControlValueInBaseCurrency
+        "1.2",  # DocumentToBaseCurrencyRate
+        "1.1",  # DocumentToAccountCurrencyRate
+        "2024-02-05",  # PostedDate
+        "Q1",  # QueryCode
+        "TRX123",  # TransactionReference
+        "SREF123",  # SecondReference
+        "80",  # Source
+        "4",  # SYSTraderTranType
+        "2024-02-01",  # TransactionDate
+        "UR123",  # UniqueReferenceNumber
+        "42",  # UserNumber
+        "10",  # TaxValue
+        "1000",  # SYSTraderGenerationReasonType
+        "1200",  # GoodsValueInBaseCurrency
+
+        # NominalAnalysis repeating values (Example: /1)
+        "500.75",  # NominalAnalysisTransactionValue/1
+        "ACC100",  # NominalAnalysisNominalAccountNumber/1
+        "CC100",  # NominalAnalysisNominalCostCentre/1
+        "DEP200",  # NominalAnalysisNominalDepartment/1
+        "Sample Narrative",  # NominalAnalysisNominalAnalysisNarrative/1
+        "TAC100",  # NominalAnalysisTransactionAnalysisCode/1
+
+        # TaxAnalysis repeating values (Example: /1)
+        "20.5",  # TaxAnalysisTaxRate/1
+        "900",  # TaxAnalysisGoodsValueBeforeDiscount/1
+        "30",  # TaxAnalysisDiscountValue/1
+        "3.5",  # TaxAnalysisDiscountPercentage/1
+        "180",  # TaxAnalysisTaxOnGoodsValue/1
+    ])
+
+    return SimpleUploadedFile("test.csv", f"{csv_headers}\n{csv_content}".encode("utf-8"), content_type="text/csv")
+
+@pytest.fixture
+def mock_meo_database(mocker):
+    """Mock the meo database since it's read-only."""
+    fake = Faker()
+
+    # Mock MeoValidSuppliers
+    supplier_mock = MagicMock()
+    supplier_mock.all.return_value = [
+        MeoValidSuppliers(supplier_account_number=str(fake.random_int(min=10000, max=99999)), supplier_account_name=fake.company()),
+        MeoValidSuppliers(supplier_account_number="12345", supplier_account_name=fake.company())
+    ]
+    mocker.patch("sage_validation.file_validator.models.MeoValidSuppliers.objects.using", return_value=supplier_mock)
+
+    # Mock MeoCostCentres
+    cost_centre_mock = MagicMock()
+    cost_centre_mock.all.return_value = [
+        MeoCostCentres(cc="CC100", cc_type="Project", cc_name="CostCentreName", id=1),
+        MeoCostCentres(cc="CC200", cc_type="Overhead", cc_name="OverheadName", id=2),
+        MeoCostCentres(cc="CC300", cc_type="Overhead", cc_name="DepartmentName", id=3)
+    ]
+    mocker.patch("sage_validation.file_validator.models.MeoCostCentres.objects.using", return_value=cost_centre_mock)
+
+    # Mock XxData
+    xx_data_mock = MagicMock()
+    xx_data_mock.all.return_value = [
+        XxData(xx_value="N100", project="ProjectCode", overhead="OverheadCode", description=fake.sentence()),
+        XxData(xx_value="N200", project="ProjectCode", overhead="OverheadCode", description=fake.sentence())
+    ]
+    mocker.patch("sage_validation.file_validator.models.XxData.objects.using", return_value=xx_data_mock)
+
+    # Mock MeoValidSageAccounts
+    sage_account_mock = MagicMock()
+    sage_account_mock.filter.return_value.exists.return_value = True
+    mocker.patch("sage_validation.file_validator.models.MeoValidSageAccounts.objects.using", return_value=sage_account_mock)
+
+    # Mock MeoNominal
+    nominal_mock = MagicMock()
+    nominal_mock.filter.return_value.exists.return_value = True
+    mocker.patch("sage_validation.file_validator.models.MeoNominal.objects.using", return_value=nominal_mock)
+
diff --git a/test/settings.py b/test/settings.py
new file mode 100644
index 0000000..a5690fb
--- /dev/null
+++ b/test/settings.py
@@ -0,0 +1,5 @@
+from sage_validation.settings import *  # noqa: F403, F401
+
+DATABASES = {
+    "default": DATABASES["default"],
+}
-- 
GitLab


From caf47cb1eaf1ecda50602fb1ffa09d0ea5df771d Mon Sep 17 00:00:00 2001
From: Neda Moeini <neda.moeini@geant.org>
Date: Thu, 27 Feb 2025 14:15:51 +0100
Subject: [PATCH 2/9] Add unittests for form validator

---
 test/conftest.py                       |  2 +-
 test/test_file_validator/__init__.py   |  0
 test/test_file_validator/test_forms.py | 52 ++++++++++++++++++++++++++
 3 files changed, 53 insertions(+), 1 deletion(-)
 create mode 100644 test/test_file_validator/__init__.py
 create mode 100644 test/test_file_validator/test_forms.py

diff --git a/test/conftest.py b/test/conftest.py
index 1d75fae..dc7f3aa 100644
--- a/test/conftest.py
+++ b/test/conftest.py
@@ -99,7 +99,7 @@ def mock_meo_database(mocker):
     supplier_mock = MagicMock()
     supplier_mock.all.return_value = [
         MeoValidSuppliers(supplier_account_number=str(fake.random_int(min=10000, max=99999)), supplier_account_name=fake.company()),
-        MeoValidSuppliers(supplier_account_number="12345", supplier_account_name=fake.company())
+        MeoValidSuppliers(supplier_account_number="12345", supplier_account_name="Sample Narrative")
     ]
     mocker.patch("sage_validation.file_validator.models.MeoValidSuppliers.objects.using", return_value=supplier_mock)
 
diff --git a/test/test_file_validator/__init__.py b/test/test_file_validator/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/test/test_file_validator/test_forms.py b/test/test_file_validator/test_forms.py
new file mode 100644
index 0000000..db0a25a
--- /dev/null
+++ b/test/test_file_validator/test_forms.py
@@ -0,0 +1,52 @@
+import csv
+import io
+
+from django.core.files.uploadedfile import SimpleUploadedFile
+
+from sage_validation.file_validator.forms import CSVUploadForm
+
+
+def test_valid_csv_upload(sample_input_file, mock_meo_database):
+    """Test CSV upload with valid data."""
+    form = CSVUploadForm(files={"file": sample_input_file})
+    assert form.is_valid(), f"Form errors: {form.errors}"
+
+
+def test_invalid_file_extension():
+    """Test form rejects non-CSV files."""
+    bad_file = SimpleUploadedFile("test.txt", b"Some text content", content_type="text/plain")
+    form = CSVUploadForm(files={"file": bad_file})
+    assert not form.is_valid()
+    assert "File must be in CSV format." in form.errors["file"]
+
+
+def test_missing_required_columns():
+    """Test form rejects CSV missing required headers."""
+    invalid_csv = SimpleUploadedFile("test.csv", b"AccountNumber,CBAccountNumber\n12345,54321", content_type="text/csv")
+    form = CSVUploadForm(files={"file": invalid_csv})
+    assert not form.is_valid()
+    assert "Missing required columns" in str(form.errors)
+
+
+def test_source_and_trader_type_validation(sample_input_file, mock_meo_database):
+    """Test validation for Source and SYSTraderTranType columns."""
+
+    csv_content = sample_input_file.read().decode("utf-8").splitlines()
+    reader = csv.DictReader(csv_content)
+    rows = list(reader)
+
+    # Modify the first row's "Source" and "SYSTraderTranType" values
+    rows[0]["Source"] = "90"  # Change Source from 80 to 90
+    rows[0]["SYSTraderTranType"] = "5"  # Change SYSTraderTranType from 4 to 5
+
+    # Rebuild the CSV with modified values
+    output = io.StringIO()
+    writer = csv.DictWriter(output, fieldnames=reader.fieldnames)
+    writer.writeheader()
+    writer.writerows(rows)
+    modified_file = SimpleUploadedFile("test_modified.csv", output.getvalue().encode("utf-8"), content_type="text/csv")
+
+    form = CSVUploadForm(files={"file": modified_file})
+    assert not form.is_valid(), "Form should be invalid due to incorrect Source and SYSTraderTranType values"
+    assert "Row 1: 'Source' must be 80" in form.errors["file"][0]
+    assert "Row 1: 'SYSTraderTranType' must be 4" in form.errors["file"][1]
-- 
GitLab


From 36a671c62bae9aef3170f7f22deb483196113b90 Mon Sep 17 00:00:00 2001
From: Neda Moeini <neda.moeini@geant.org>
Date: Thu, 27 Feb 2025 16:07:25 +0100
Subject: [PATCH 3/9] Test validate nc cc dep combination against meo sage
 account

---
 test/test_file_validator/test_forms.py | 44 +++++++++++++++++++++++++-
 1 file changed, 43 insertions(+), 1 deletion(-)

diff --git a/test/test_file_validator/test_forms.py b/test/test_file_validator/test_forms.py
index db0a25a..583fbbe 100644
--- a/test/test_file_validator/test_forms.py
+++ b/test/test_file_validator/test_forms.py
@@ -47,6 +47,48 @@ def test_source_and_trader_type_validation(sample_input_file, mock_meo_database)
     modified_file = SimpleUploadedFile("test_modified.csv", output.getvalue().encode("utf-8"), content_type="text/csv")
 
     form = CSVUploadForm(files={"file": modified_file})
-    assert not form.is_valid(), "Form should be invalid due to incorrect Source and SYSTraderTranType values"
+    assert not form.is_valid()
     assert "Row 1: 'Source' must be 80" in form.errors["file"][0]
     assert "Row 1: 'SYSTraderTranType' must be 4" in form.errors["file"][1]
+
+
+def test_validate_nominal_analysis_account(sample_input_file, mock_meo_database):
+    """Test validation for nominal analysis account."""
+
+    csv_content = sample_input_file.read().decode("utf-8").splitlines()
+    reader = csv.DictReader(csv_content)
+    rows = list(reader)
+    rows[0]["NominalAnalysisNominalAnalysisNarrative/1"] = "Invalid Name"
+
+    # Rebuild the CSV with modified values
+    output = io.StringIO()
+    writer = csv.DictWriter(output, fieldnames=reader.fieldnames)
+    writer.writeheader()
+    writer.writerows(rows)
+    modified_file = SimpleUploadedFile("test_modified.csv", output.getvalue().encode("utf-8"), content_type="text/csv")
+
+    form = CSVUploadForm(files={"file": modified_file})
+    assert not form.is_valid()
+    assert (f"Row 1: 'AccountNumber' must match 'Sample Narrative' in 'NominalAnalysisNominalAnalysisNarrative/1',"
+            f" but found 'Invalid Name'.") == form.errors["file"][0]
+
+
+def test_validate_nc_cc_dep_combination_against_meo_sage_account(sample_input_file, mock_meo_database):
+    """Test validation for nominal analysis fields against MEO valid Sage accounts."""
+    csv_content = sample_input_file.read().decode("utf-8").splitlines()
+    reader = csv.DictReader(csv_content)
+    rows = list(reader)
+
+    rows[0]["NominalAnalysisNominalCostCentre/1"] = "Invalid_CC"
+    rows[0]["NominalAnalysisNominalAccountNumber/1"] = "Invalid_Account"
+
+    output = io.StringIO()
+    writer = csv.DictWriter(output, fieldnames=reader.fieldnames)
+    writer.writeheader()
+    writer.writerows(rows)
+    modified_file = SimpleUploadedFile("test_modified.csv", output.getvalue().encode("utf-8"), content_type="text/csv")
+
+    form = CSVUploadForm(files={"file": modified_file})
+    assert not form.is_valid()
+    assert "Row 1: 'NominalAnalysisNominalCostCentre/1' (Invalid_CC) is not a valid cost centre." in str(form.errors["file"][0])
+
-- 
GitLab


From adb279d139cb8b1afc85e79e7fb838306da4d63d Mon Sep 17 00:00:00 2001
From: Neda Moeini <neda.moeini@geant.org>
Date: Thu, 27 Feb 2025 16:34:56 +0100
Subject: [PATCH 4/9] Make ruff happy

---
 test/conftest.py                       | 29 +++++++++++++++++---------
 test/settings.py                       |  6 ++++--
 test/test_file_validator/test_forms.py | 11 +++++-----
 3 files changed, 29 insertions(+), 17 deletions(-)

diff --git a/test/conftest.py b/test/conftest.py
index dc7f3aa..74bbc11 100644
--- a/test/conftest.py
+++ b/test/conftest.py
@@ -1,14 +1,17 @@
-import pytest
+"""Fixtures for the sage_validation tests."""
 from unittest.mock import MagicMock
+
+import pytest
 from django.core.files.uploadedfile import SimpleUploadedFile
-from sage_validation.file_validator.models import MeoValidSuppliers, MeoCostCentres, XxData
 from faker import Faker
 
+from sage_validation.file_validator.models import MeoCostCentres, MeoValidSuppliers, XxData
+
 
 @pytest.fixture
 def sample_input_file():
-    """Creates a sample valid CSV file for testing."""
-    csv_headers = ",".join([
+    """Create a sample valid CSV file for testing."""
+    csv_headers_list = [
         "AccountNumber",
         "CBAccountNumber",
         "DaysDiscountValid",
@@ -46,9 +49,10 @@ def sample_input_file():
         "TaxAnalysisDiscountValue/1",
         "TaxAnalysisDiscountPercentage/1",
         "TaxAnalysisTaxOnGoodsValue/1",
-    ])
+    ]
+    csv_headers = ",".join(csv_headers_list)
 
-    csv_content = ",".join([
+    csv_content_list = [
         "12345",  # AccountNumber
         "54321",  # CBAccountNumber
         "30",  # DaysDiscountValid
@@ -86,9 +90,10 @@ def sample_input_file():
         "30",  # TaxAnalysisDiscountValue/1
         "3.5",  # TaxAnalysisDiscountPercentage/1
         "180",  # TaxAnalysisTaxOnGoodsValue/1
-    ])
+    ]
+    csv_content = ",".join(csv_content_list)
 
-    return SimpleUploadedFile("test.csv", f"{csv_headers}\n{csv_content}".encode("utf-8"), content_type="text/csv")
+    return SimpleUploadedFile("test.csv", f"{csv_headers}\n{csv_content}".encode(), content_type="text/csv")
 
 @pytest.fixture
 def mock_meo_database(mocker):
@@ -98,7 +103,9 @@ def mock_meo_database(mocker):
     # Mock MeoValidSuppliers
     supplier_mock = MagicMock()
     supplier_mock.all.return_value = [
-        MeoValidSuppliers(supplier_account_number=str(fake.random_int(min=10000, max=99999)), supplier_account_name=fake.company()),
+        MeoValidSuppliers(
+            supplier_account_number=str(fake.random_int(min=10000, max=99999)), supplier_account_name=fake.company()
+        ),
         MeoValidSuppliers(supplier_account_number="12345", supplier_account_name="Sample Narrative")
     ]
     mocker.patch("sage_validation.file_validator.models.MeoValidSuppliers.objects.using", return_value=supplier_mock)
@@ -123,7 +130,9 @@ def mock_meo_database(mocker):
     # Mock MeoValidSageAccounts
     sage_account_mock = MagicMock()
     sage_account_mock.filter.return_value.exists.return_value = True
-    mocker.patch("sage_validation.file_validator.models.MeoValidSageAccounts.objects.using", return_value=sage_account_mock)
+    mocker.patch(
+        "sage_validation.file_validator.models.MeoValidSageAccounts.objects.using", return_value=sage_account_mock
+    )
 
     # Mock MeoNominal
     nominal_mock = MagicMock()
diff --git a/test/settings.py b/test/settings.py
index a5690fb..225f265 100644
--- a/test/settings.py
+++ b/test/settings.py
@@ -1,5 +1,7 @@
-from sage_validation.settings import *  # noqa: F403, F401
+"""Settings for running tests."""
+
+from sage_validation.settings import *  # noqa: F403
 
 DATABASES = {
-    "default": DATABASES["default"],
+    "default": DATABASES["default"],  # noqa: F405
 }
diff --git a/test/test_file_validator/test_forms.py b/test/test_file_validator/test_forms.py
index 583fbbe..b169538 100644
--- a/test/test_file_validator/test_forms.py
+++ b/test/test_file_validator/test_forms.py
@@ -1,3 +1,4 @@
+"""Tests for the file_validator forms."""
 import csv
 import io
 
@@ -30,7 +31,6 @@ def test_missing_required_columns():
 
 def test_source_and_trader_type_validation(sample_input_file, mock_meo_database):
     """Test validation for Source and SYSTraderTranType columns."""
-
     csv_content = sample_input_file.read().decode("utf-8").splitlines()
     reader = csv.DictReader(csv_content)
     rows = list(reader)
@@ -54,7 +54,6 @@ def test_source_and_trader_type_validation(sample_input_file, mock_meo_database)
 
 def test_validate_nominal_analysis_account(sample_input_file, mock_meo_database):
     """Test validation for nominal analysis account."""
-
     csv_content = sample_input_file.read().decode("utf-8").splitlines()
     reader = csv.DictReader(csv_content)
     rows = list(reader)
@@ -69,8 +68,9 @@ def test_validate_nominal_analysis_account(sample_input_file, mock_meo_database)
 
     form = CSVUploadForm(files={"file": modified_file})
     assert not form.is_valid()
-    assert (f"Row 1: 'AccountNumber' must match 'Sample Narrative' in 'NominalAnalysisNominalAnalysisNarrative/1',"
-            f" but found 'Invalid Name'.") == form.errors["file"][0]
+    assert form.errors["file"][0] == (
+        "Row 1: 'AccountNumber' must match 'Sample Narrative' in 'NominalAnalysisNominalAnalysisNarrative/1'"
+        ", but found 'Invalid Name'.")
 
 
 def test_validate_nc_cc_dep_combination_against_meo_sage_account(sample_input_file, mock_meo_database):
@@ -90,5 +90,6 @@ def test_validate_nc_cc_dep_combination_against_meo_sage_account(sample_input_fi
 
     form = CSVUploadForm(files={"file": modified_file})
     assert not form.is_valid()
-    assert "Row 1: 'NominalAnalysisNominalCostCentre/1' (Invalid_CC) is not a valid cost centre." in str(form.errors["file"][0])
+    assert ("Row 1: 'NominalAnalysisNominalCostCentre/1' (Invalid_CC) is not a valid cost centre."
+            in str(form.errors["file"][0]))
 
-- 
GitLab


From 82236f1712bbf176556c219c11ed14ce9c65d1fe Mon Sep 17 00:00:00 2001
From: Neda Moeini <neda.moeini@geant.org>
Date: Thu, 27 Feb 2025 16:35:27 +0100
Subject: [PATCH 5/9] Move pytest configuration to pyproject.toml

---
 pyproject.toml | 6 ++++++
 pytest.ini     | 4 ----
 2 files changed, 6 insertions(+), 4 deletions(-)
 delete mode 100644 pytest.ini

diff --git a/pyproject.toml b/pyproject.toml
index 491a227..981838d 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -37,4 +37,10 @@ line-length = 120
 [tool.ruff.lint.flake8-tidy-imports]
 ban-relative-imports = "all"
 
+[tool.ruff.lint.per-file-ignores]
+"test/*" = ["S101", "ARG001",]
 
+[tool.pytest.ini_options]
+DJANGO_SETTINGS_MODULE = "test.settings"
+django_find_project = false
+python_files = ["tests.py", "test_*.py", "*_tests.py"]
\ No newline at end of file
diff --git a/pytest.ini b/pytest.ini
deleted file mode 100644
index 8872a16..0000000
--- a/pytest.ini
+++ /dev/null
@@ -1,4 +0,0 @@
-[pytest]
-DJANGO_SETTINGS_MODULE = test.settings
-django_find_project = false
-python_files = tests.py test_*.py *_tests.py
\ No newline at end of file
-- 
GitLab


From e2033a5cc6318e60fee2be6877b3c2dce42970a7 Mon Sep 17 00:00:00 2001
From: Neda Moeini <neda.moeini@geant.org>
Date: Thu, 27 Feb 2025 16:35:49 +0100
Subject: [PATCH 6/9] Add coverage

---
 requirements.txt | 1 +
 tox.ini          | 7 +++++++
 2 files changed, 8 insertions(+)

diff --git a/requirements.txt b/requirements.txt
index af546b5..9732a52 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -9,3 +9,4 @@ pytest
 pytest-django
 pytest-mock
 faker
+coverage
diff --git a/tox.ini b/tox.ini
index 91d0161..9380288 100644
--- a/tox.ini
+++ b/tox.ini
@@ -5,6 +5,13 @@ envlist = py311
 deps =
     mypy
     ruff
+    -r requirements.txt
 commands =
     ruff check .
     mypy .
+    coverage erase
+    coverage run --source sage_validation -m pytest
+    coverage report --fail-under=90
+    coverage xml
+    coverage html
+
-- 
GitLab


From e95043467108a410a198fddc8db0d5000f4b7f12 Mon Sep 17 00:00:00 2001
From: Neda Moeini <neda.moeini@geant.org>
Date: Fri, 28 Feb 2025 11:06:54 +0100
Subject: [PATCH 7/9] Improve the tests by using a helper function for
 modifying the input file and removed the redundant code

---
 pyproject.toml                         |  2 +-
 test/conftest.py                       |  4 +-
 test/test_file_validator/test_forms.py | 89 +++++++++++++-------------
 3 files changed, 47 insertions(+), 48 deletions(-)

diff --git a/pyproject.toml b/pyproject.toml
index 981838d..4650bd8 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -38,7 +38,7 @@ line-length = 120
 ban-relative-imports = "all"
 
 [tool.ruff.lint.per-file-ignores]
-"test/*" = ["S101", "ARG001",]
+"test/*" = ["ARG001", "D", "S101", "PLR2004", "PLR0917", "PLR0914", "PLC0415", "PLC2701"]
 
 [tool.pytest.ini_options]
 DJANGO_SETTINGS_MODULE = "test.settings"
diff --git a/test/conftest.py b/test/conftest.py
index 74bbc11..f91236c 100644
--- a/test/conftest.py
+++ b/test/conftest.py
@@ -9,7 +9,7 @@ from sage_validation.file_validator.models import MeoCostCentres, MeoValidSuppli
 
 
 @pytest.fixture
-def sample_input_file():
+def sample_input_file() -> SimpleUploadedFile:
     """Create a sample valid CSV file for testing."""
     csv_headers_list = [
         "AccountNumber",
@@ -96,7 +96,7 @@ def sample_input_file():
     return SimpleUploadedFile("test.csv", f"{csv_headers}\n{csv_content}".encode(), content_type="text/csv")
 
 @pytest.fixture
-def mock_meo_database(mocker):
+def mock_meo_database(mocker: MagicMock)-> None:
     """Mock the meo database since it's read-only."""
     fake = Faker()
 
diff --git a/test/test_file_validator/test_forms.py b/test/test_file_validator/test_forms.py
index b169538..b5df108 100644
--- a/test/test_file_validator/test_forms.py
+++ b/test/test_file_validator/test_forms.py
@@ -1,19 +1,46 @@
 """Tests for the file_validator forms."""
 import csv
 import io
+from unittest.mock import MagicMock
 
 from django.core.files.uploadedfile import SimpleUploadedFile
 
 from sage_validation.file_validator.forms import CSVUploadForm
 
 
-def test_valid_csv_upload(sample_input_file, mock_meo_database):
+def create_modified_csv(sample_file: SimpleUploadedFile, modifications: dict[str, str]) -> SimpleUploadedFile:
+    """
+    Modify specific fields in the first row of a CSV file and return a new SimpleUploadedFile.
+
+    Args:
+        sample_file (SimpleUploadedFile): The original CSV file.
+        modifications (dict): Dictionary of column names to modified values.
+
+    Returns:
+        SimpleUploadedFile: The modified CSV file.
+    """
+    csv_content = sample_file.read().decode("utf-8").splitlines()
+    reader = csv.DictReader(csv_content)
+    rows = list(reader)
+
+    for key, value in modifications.items():
+        rows[0][key] = value
+
+    output = io.StringIO()
+    writer = csv.DictWriter(output, fieldnames=reader.fieldnames or [])
+    writer.writeheader()
+    writer.writerows(rows)
+
+    return SimpleUploadedFile("test_modified.csv", output.getvalue().encode("utf-8"), content_type="text/csv")
+
+
+def test_valid_csv_upload(sample_input_file: SimpleUploadedFile, mock_meo_database: MagicMock) -> None:
     """Test CSV upload with valid data."""
     form = CSVUploadForm(files={"file": sample_input_file})
     assert form.is_valid(), f"Form errors: {form.errors}"
 
 
-def test_invalid_file_extension():
+def test_invalid_file_extension() -> None:
     """Test form rejects non-CSV files."""
     bad_file = SimpleUploadedFile("test.txt", b"Some text content", content_type="text/plain")
     form = CSVUploadForm(files={"file": bad_file})
@@ -21,7 +48,7 @@ def test_invalid_file_extension():
     assert "File must be in CSV format." in form.errors["file"]
 
 
-def test_missing_required_columns():
+def test_missing_required_columns() -> None:
     """Test form rejects CSV missing required headers."""
     invalid_csv = SimpleUploadedFile("test.csv", b"AccountNumber,CBAccountNumber\n12345,54321", content_type="text/csv")
     form = CSVUploadForm(files={"file": invalid_csv})
@@ -29,43 +56,19 @@ def test_missing_required_columns():
     assert "Missing required columns" in str(form.errors)
 
 
-def test_source_and_trader_type_validation(sample_input_file, mock_meo_database):
+def test_source_and_trader_type_validation(sample_input_file: SimpleUploadedFile, mock_meo_database: MagicMock) -> None:
     """Test validation for Source and SYSTraderTranType columns."""
-    csv_content = sample_input_file.read().decode("utf-8").splitlines()
-    reader = csv.DictReader(csv_content)
-    rows = list(reader)
-
-    # Modify the first row's "Source" and "SYSTraderTranType" values
-    rows[0]["Source"] = "90"  # Change Source from 80 to 90
-    rows[0]["SYSTraderTranType"] = "5"  # Change SYSTraderTranType from 4 to 5
-
-    # Rebuild the CSV with modified values
-    output = io.StringIO()
-    writer = csv.DictWriter(output, fieldnames=reader.fieldnames)
-    writer.writeheader()
-    writer.writerows(rows)
-    modified_file = SimpleUploadedFile("test_modified.csv", output.getvalue().encode("utf-8"), content_type="text/csv")
-
+    modified_file = create_modified_csv(sample_input_file, {"Source": "90", "SYSTraderTranType": "5"})
     form = CSVUploadForm(files={"file": modified_file})
     assert not form.is_valid()
     assert "Row 1: 'Source' must be 80" in form.errors["file"][0]
     assert "Row 1: 'SYSTraderTranType' must be 4" in form.errors["file"][1]
 
 
-def test_validate_nominal_analysis_account(sample_input_file, mock_meo_database):
+def test_validate_nominal_analysis_account(sample_input_file: SimpleUploadedFile, mock_meo_database: MagicMock) -> None:
     """Test validation for nominal analysis account."""
-    csv_content = sample_input_file.read().decode("utf-8").splitlines()
-    reader = csv.DictReader(csv_content)
-    rows = list(reader)
-    rows[0]["NominalAnalysisNominalAnalysisNarrative/1"] = "Invalid Name"
-
-    # Rebuild the CSV with modified values
-    output = io.StringIO()
-    writer = csv.DictWriter(output, fieldnames=reader.fieldnames)
-    writer.writeheader()
-    writer.writerows(rows)
-    modified_file = SimpleUploadedFile("test_modified.csv", output.getvalue().encode("utf-8"), content_type="text/csv")
-
+    modified_file = create_modified_csv(sample_input_file,
+                                        {"NominalAnalysisNominalAnalysisNarrative/1": "Invalid Name"})
     form = CSVUploadForm(files={"file": modified_file})
     assert not form.is_valid()
     assert form.errors["file"][0] == (
@@ -73,20 +76,16 @@ def test_validate_nominal_analysis_account(sample_input_file, mock_meo_database)
         ", but found 'Invalid Name'.")
 
 
-def test_validate_nc_cc_dep_combination_against_meo_sage_account(sample_input_file, mock_meo_database):
+def test_validate_nc_cc_dep_combination_against_meo_sage_account(
+        sample_input_file: SimpleUploadedFile, mock_meo_database: MagicMock
+) -> None:
     """Test validation for nominal analysis fields against MEO valid Sage accounts."""
-    csv_content = sample_input_file.read().decode("utf-8").splitlines()
-    reader = csv.DictReader(csv_content)
-    rows = list(reader)
-
-    rows[0]["NominalAnalysisNominalCostCentre/1"] = "Invalid_CC"
-    rows[0]["NominalAnalysisNominalAccountNumber/1"] = "Invalid_Account"
-
-    output = io.StringIO()
-    writer = csv.DictWriter(output, fieldnames=reader.fieldnames)
-    writer.writeheader()
-    writer.writerows(rows)
-    modified_file = SimpleUploadedFile("test_modified.csv", output.getvalue().encode("utf-8"), content_type="text/csv")
+    modified_file = create_modified_csv(
+        sample_input_file,
+        {
+            "NominalAnalysisNominalCostCentre/1": "Invalid_CC",
+            "NominalAnalysisNominalAccountNumber/1": "Invalid_Account"
+        })
 
     form = CSVUploadForm(files={"file": modified_file})
     assert not form.is_valid()
-- 
GitLab


From 917f8856290864bd0c8ee57e9f9fff61b7f51a4f Mon Sep 17 00:00:00 2001
From: Neda Moeini <neda.moeini@geant.org>
Date: Fri, 28 Feb 2025 11:39:57 +0100
Subject: [PATCH 8/9] Replace FormView with APIView

---
 requirements.txt                              |  1 +
 .../file_validator/templates/upload.html      | 58 +++++------------
 sage_validation/file_validator/urls.py        |  7 ++-
 sage_validation/file_validator/views.py       | 62 +++++++++----------
 sage_validation/templates/index.html          |  2 +-
 5 files changed, 50 insertions(+), 80 deletions(-)

diff --git a/requirements.txt b/requirements.txt
index 9732a52..4edebd6 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,4 +1,5 @@
 Django==5.0.11
+django-rest-framework
 ruff
 mypy
 tox
diff --git a/sage_validation/file_validator/templates/upload.html b/sage_validation/file_validator/templates/upload.html
index 4a595da..ec8619d 100644
--- a/sage_validation/file_validator/templates/upload.html
+++ b/sage_validation/file_validator/templates/upload.html
@@ -40,21 +40,18 @@
         </div>
     </div>
 
-
     <script>
-        const form = document.getElementById('uploadForm');
-        const fileInput = document.getElementById('fileInput');
-        const errorSection = document.getElementById('errorSection');
-        const errorList = document.getElementById('errorList');
-        const successSection = document.getElementById('successSection');
-        const successMessage = document.getElementById('successMessage');
-        const downloadSection = document.getElementById('downloadSection');
-        const downloadLink = document.getElementById('downloadLink');
-
-        form.addEventListener('submit', async function (e) {
+        document.getElementById('uploadForm').addEventListener('submit', async function (e) {
             e.preventDefault();
 
-            // Clear previous messages
+            const fileInput = document.getElementById('fileInput');
+            const errorSection = document.getElementById('errorSection');
+            const errorList = document.getElementById('errorList');
+            const successSection = document.getElementById('successSection');
+            const successMessage = document.getElementById('successMessage');
+            const downloadSection = document.getElementById('downloadSection');
+            const downloadLink = document.getElementById('downloadLink');
+
             errorList.innerHTML = '';
             successMessage.innerHTML = '';
             errorSection.classList.add('hidden');
@@ -65,7 +62,7 @@
             formData.append('file', fileInput.files[0]);
 
             try {
-                const response = await fetch('', {
+                const response = await fetch("{% url 'upload-file' %}", {
                     method: 'POST',
                     body: formData,
                     headers: {
@@ -81,38 +78,13 @@
                     downloadLink.href = result.download_url;
                     downloadSection.classList.remove('hidden');
                 } else if (response.status === 400 && result.status === 'error') {
-                    errorList.innerHTML = '';
-
-                    if (Array.isArray(result.errors)) {
-                        result.errors.forEach(errorObj => {
-                            if (typeof errorObj === 'string') {
-                                const li = document.createElement('li');
-                                li.textContent = errorObj;
-                                errorList.appendChild(li);
-                            } else {
-                                for (const [field, messages] of Object.entries(errorObj)) {
-                                    messages.forEach(message => {
-                                        const li = document.createElement('li');
-                                        li.textContent = `${field}: ${message}`;
-                                        errorList.appendChild(li);
-                                    });
-                                }
-                            }
+                    for (const [field, messages] of Object.entries(result.errors)) {
+                        messages.forEach(message => {
+                            const li = document.createElement('li');
+                            li.textContent = `${field}: ${message}`;
+                            errorList.appendChild(li);
                         });
-                    } else if (typeof result.errors === 'object') {
-                        for (const [field, messages] of Object.entries(result.errors)) {
-                            messages.forEach(message => {
-                                const li = document.createElement('li');
-                                li.textContent = `${field}: ${message}`;
-                                errorList.appendChild(li);
-                            });
-                        }
-                    } else {
-                        const li = document.createElement('li');
-                        li.textContent = result.errors;
-                        errorList.appendChild(li);
                     }
-
                     errorSection.classList.remove('hidden');
                 }
             } catch (error) {
diff --git a/sage_validation/file_validator/urls.py b/sage_validation/file_validator/urls.py
index a5c0d9f..ee5ae78 100644
--- a/sage_validation/file_validator/urls.py
+++ b/sage_validation/file_validator/urls.py
@@ -2,9 +2,10 @@
 
 from django.urls import path
 
-from sage_validation.file_validator.views import CSVExportView, CSVUploadView
+from sage_validation.file_validator.views import CSVExportAPIView, CSVUploadAPIView, upload_page_view
 
 urlpatterns = [
-    path("upload/", CSVUploadView.as_view(), name="upload-file"),
-    path("export/", CSVExportView.as_view(), name="export-file"),
+    path("upload-page/", upload_page_view, name="upload-page"),
+    path("api/upload/", CSVUploadAPIView.as_view(), name="upload-file"),
+    path("api/export/", CSVExportAPIView.as_view(), name="export-file"),
 ]
diff --git a/sage_validation/file_validator/views.py b/sage_validation/file_validator/views.py
index 99efd17..bfa80c2 100644
--- a/sage_validation/file_validator/views.py
+++ b/sage_validation/file_validator/views.py
@@ -1,14 +1,15 @@
 """Views for the file_validator app."""
 import csv
 import io
-from typing import Any
 
-from django.http import HttpRequest, HttpResponse, JsonResponse
+from django.http import HttpRequest, HttpResponse
 from django.shortcuts import render
 from django.urls import reverse_lazy
 from django.utils import timezone
-from django.views.generic.base import View
-from django.views.generic.edit import FormView
+from rest_framework import status
+from rest_framework.request import Request
+from rest_framework.response import Response
+from rest_framework.views import APIView
 
 from sage_validation.file_validator.forms import CSVUploadForm
 from sage_validation.file_validator.models import MeoCostCentres, XxData
@@ -19,46 +20,41 @@ def index_view(request: HttpRequest) -> HttpResponse:
     return render(request, "index.html")
 
 
-class CSVUploadView(FormView):
-    """View for uploading a CSV file."""
+def upload_page_view(request: HttpRequest) -> HttpResponse:
+    """Render the file upload page."""
+    return render(request, "upload.html")
 
-    template_name = "upload.html"
-    form_class = CSVUploadForm
-    success_url = reverse_lazy("upload-file")
 
-    def get_context_data(self, **kwargs: dict[str, Any]) -> dict[str, Any]:
-        """Render the form with no error message on GET request."""
-        context = super().get_context_data(**kwargs)
-        context["error"] = None
-        context["message"] = None
-        return context
+class CSVUploadAPIView(APIView):
+    """API view for uploading a CSV file."""
+
+    def post(self, request: Request) -> Response:
+        """Handle CSV upload and validation."""
+        form = CSVUploadForm(data=request.data, files=request.FILES)
+
+        if not form.is_valid():
+            return Response({"status": "error", "errors": form.errors}, status=status.HTTP_400_BAD_REQUEST)
 
-    def form_valid(self, form: CSVUploadForm) -> JsonResponse:
-        """Handle the CSV validation, store valid data, and prepare for export."""
         csv_file = form.cleaned_data["file"]
         csv_file.seek(0)
         decoded_file = csv_file.read().decode("utf-8").strip()
 
         if not decoded_file:
-            return JsonResponse({"status": "error", "message": "Uploaded file is empty."}, status=400)
+            return Response({"status": "error", "message": "Uploaded file is empty."},
+                            status=status.HTTP_400_BAD_REQUEST)
 
         reader = csv.DictReader(io.StringIO(decoded_file))
         csv_data: list[dict[str, str]] = list(reader)
 
         updated_data = self.update_fields(csv_data)
+        request.session["validated_csv"] = updated_data
+        request.session.modified = True
 
-        self.request.session["validated_csv"] = updated_data
-        self.request.session.modified = True
-
-        return JsonResponse({
+        return Response({
             "status": "success",
             "message": "File successfully uploaded and processed.",
             "download_url": reverse_lazy("export-file")
-        })
-
-    def form_invalid(self, form: CSVUploadForm) -> JsonResponse:
-        """Handle the form when it is invalid."""
-        return JsonResponse({"status": "error", "errors": form.errors}, status=400)
+        }, status=status.HTTP_200_OK)
 
     @staticmethod
     def update_fields(csv_data: list[dict[str, str]]) -> list[dict[str, str]]:
@@ -91,21 +87,21 @@ class CSVUploadView(FormView):
                     row[f"NominalAnalysisNominalAccountNumber/{repeat}"] = (
                         xx_data[0] if cc_type == "Project" else xx_data[1]
                     )
-
                 repeat += 1
 
         return csv_data
 
 
-class CSVExportView(View):
-    """View for exporting the updated CSV file."""
+class CSVExportAPIView(APIView):
+    """API view for exporting the updated CSV file."""
 
-    def get(self, request: HttpRequest) -> HttpResponse:
-        """Generate a downloadable CSV file with updated values."""
+    def get(self, request: Request) -> Response:
+        """Return processed CSV as a downloadable response."""
         csv_data: list[dict[str, str]] = request.session.get("validated_csv", [])
 
         if not csv_data:
-            return HttpResponse("No data available for export.", status=400)
+            return Response({"status": "error", "message": "No data available for export."},
+                            status=status.HTTP_400_BAD_REQUEST)
 
         response = HttpResponse(content_type="text/csv")
         response["Content-Disposition"] = 'attachment; filename="updated_file.csv"'
diff --git a/sage_validation/templates/index.html b/sage_validation/templates/index.html
index 7d7376e..72db6b8 100644
--- a/sage_validation/templates/index.html
+++ b/sage_validation/templates/index.html
@@ -8,7 +8,7 @@
         <h1 class="text-5xl md:text-6xl font-bold mb-12 text-blue-900">Welcome to Sage Validation</h1>
         <p class="text-xl md:text-2xl mb-16 text-gray-700">Click the button below to upload your file for validation.</p>
 
-        <a href="{% url "upload-file" %}"
+        <a href="{% url "upload-page" %}"
            class="inline-flex py-4 px-16 bg-blue-600 text-white text-lg md:text-xl font-bold rounded-full shadow-lg transition-transform transform hover:scale-105 focus:outline-none focus:ring-4 focus:ring-blue-300 focus:ring-opacity-50">
             Upload File
         </a>
-- 
GitLab


From cdbcc75afbafd4258a7c56ec63559a287c6106bd Mon Sep 17 00:00:00 2001
From: Neda Moeini <neda.moeini@geant.org>
Date: Fri, 28 Feb 2025 13:07:28 +0100
Subject: [PATCH 9/9] Add more tests for endpoints

---
 sage_validation/settings.py                   |  2 +-
 test/conftest.py                              |  6 +++
 .../test_file_validator_endpoints.py          | 52 +++++++++++++++++++
 3 files changed, 59 insertions(+), 1 deletion(-)
 create mode 100644 test/test_file_validator/test_file_validator_endpoints.py

diff --git a/sage_validation/settings.py b/sage_validation/settings.py
index 1a00d60..353594e 100644
--- a/sage_validation/settings.py
+++ b/sage_validation/settings.py
@@ -18,7 +18,7 @@ BASE_DIR = Path(__file__).resolve().parent
 # See https://docs.djangoproject.com/en/5.1/howto/deployment/checklist/
 
 # SECURITY WARNING: keep the secret key used in production secret!
-SECRET_KEY = os.getenv("SECRET_KEY")
+SECRET_KEY = os.getenv("SECRET_KEY", "test-secret-key")
 
 # SECURITY WARNING: don't run with debug turned on in production!
 DEBUG = True
diff --git a/test/conftest.py b/test/conftest.py
index f91236c..f32a594 100644
--- a/test/conftest.py
+++ b/test/conftest.py
@@ -4,6 +4,7 @@ from unittest.mock import MagicMock
 import pytest
 from django.core.files.uploadedfile import SimpleUploadedFile
 from faker import Faker
+from rest_framework.test import APIClient
 
 from sage_validation.file_validator.models import MeoCostCentres, MeoValidSuppliers, XxData
 
@@ -139,3 +140,8 @@ def mock_meo_database(mocker: MagicMock)-> None:
     nominal_mock.filter.return_value.exists.return_value = True
     mocker.patch("sage_validation.file_validator.models.MeoNominal.objects.using", return_value=nominal_mock)
 
+
+@pytest.fixture
+def api_client() -> APIClient:
+    """Fixture to return Django API test client."""
+    return APIClient()
diff --git a/test/test_file_validator/test_file_validator_endpoints.py b/test/test_file_validator/test_file_validator_endpoints.py
new file mode 100644
index 0000000..3193a62
--- /dev/null
+++ b/test/test_file_validator/test_file_validator_endpoints.py
@@ -0,0 +1,52 @@
+from unittest.mock import MagicMock
+
+import pytest
+from django.core.files.uploadedfile import SimpleUploadedFile
+from django.urls.base import reverse
+from rest_framework.test import APIClient
+
+UPLOAD_FILE_URL = reverse("upload-file")
+
+
+@pytest.mark.django_db
+def test_csv_upload_valid(
+        api_client: APIClient, sample_input_file: SimpleUploadedFile, mock_meo_database: MagicMock
+) -> None:
+    """Test that a valid CSV upload succeeds."""
+    response = api_client.post(UPLOAD_FILE_URL, {"file": sample_input_file}, format="multipart")
+
+    assert response.status_code == 200
+    assert response.json()["status"] == "success"
+    assert "download_url" in response.json()
+
+
+@pytest.mark.django_db
+def test_csv_upload_invalid_extension(api_client: APIClient) -> None:
+    """Test that uploading a non-CSV file fails."""
+    bad_file = SimpleUploadedFile("test.txt", b"Invalid content", content_type="text/plain")
+
+    response = api_client.post(UPLOAD_FILE_URL, {"file": bad_file}, format="multipart")
+
+    assert response.status_code == 400
+    assert response.json()["status"] == "error"
+    assert "errors" in response.json()
+
+
+@pytest.mark.django_db
+def test_csv_export_with_data(api_client: APIClient) -> None:
+    """Test exporting a processed CSV file."""
+    url = reverse("export-file")
+
+    # Simulate session data
+    session = api_client.session
+    session["validated_csv"] = [
+        {"AccountNumber": "12345", "TransactionDate": "01/03/2024", "NominalAnalysisNominalAccountNumber/1": "N100"}
+    ]
+    session.save()
+
+    response = api_client.get(url)
+
+    assert response.status_code == 200
+    assert response["Content-Disposition"] == 'attachment; filename="updated_file.csv"'
+    assert b"AccountNumber,TransactionDate,NominalAnalysisNominalAccountNumber/1" in response.content
+    assert b"12345,01/03/2024,N100" in response.content
-- 
GitLab