Add tests

This commit is contained in:
google-labs-jules[bot]
2025-06-15 19:06:36 +00:00
parent a461a33dc2
commit bf9f8bbf3a
16 changed files with 3547 additions and 41 deletions
+422 -1
View File
@@ -1,3 +1,424 @@
from django.test import TestCase
from django.core.exceptions import ValidationError
from django.db import IntegrityError
import yaml
from decimal import Decimal
from datetime import date
# Create your tests here.
from django.test import TestCase
from django.core.exceptions import ValidationError
from django.db import IntegrityError
from apps.import_app.models import ImportProfile, ImportRun
from apps.import_app.forms import ImportProfileForm
from apps.import_app.services.v1 import ImportService
from apps.import_app.schemas import version_1
from apps.transactions.models import Transaction # For Transaction.Type
from unittest.mock import patch
import tempfile
import os
class ImportProfileTests(TestCase):
def test_import_profile_valid_yaml_v1(self):
valid_yaml_config = """
settings:
file_type: csv
delimiter: ','
encoding: utf-8
skip_lines: 0
trigger_transaction_rules: true
importing: transactions
mapping:
date:
target: date
source: Transaction Date
format: '%Y-%m-%d'
amount:
target: amount
source: Amount
description:
target: description
source: Narrative
account:
target: account
source: Account Name
type: name
type:
target: type
source: Credit Debit
detection_method: sign # Assumes positive is income, negative is expense
is_paid:
target: is_paid
detection_method: always_paid
deduplication: []
"""
profile = ImportProfile(
name="Test Valid Profile V1",
yaml_config=valid_yaml_config,
version=ImportProfile.Versions.VERSION_1
)
try:
profile.full_clean()
except ValidationError as e:
self.fail(f"Valid YAML config raised ValidationError: {e.error_dict}")
# Optional: Save and retrieve
profile.save()
retrieved_profile = ImportProfile.objects.get(pk=profile.pk)
self.assertIsNotNone(retrieved_profile)
self.assertEqual(retrieved_profile.name, "Test Valid Profile V1")
def test_import_profile_invalid_yaml_syntax_v1(self):
invalid_yaml = "settings: { file_type: csv, delimiter: ','" # Malformed YAML
profile = ImportProfile(
name="Test Invalid Syntax V1",
yaml_config=invalid_yaml,
version=ImportProfile.Versions.VERSION_1
)
with self.assertRaises(ValidationError) as cm:
profile.full_clean()
self.assertIn('yaml_config', cm.exception.error_dict)
self.assertTrue(any("YAML" in error.message.lower() or "syntax" in error.message.lower() for error in cm.exception.error_dict['yaml_config']))
def test_import_profile_schema_validation_error_v1(self):
schema_error_yaml = """
settings:
file_type: csv
importing: transactions
mapping:
date: # Missing 'format' which is required for TransactionDateMapping
target: date
source: Transaction Date
"""
profile = ImportProfile(
name="Test Schema Error V1",
yaml_config=schema_error_yaml,
version=ImportProfile.Versions.VERSION_1
)
with self.assertRaises(ValidationError) as cm:
profile.full_clean()
self.assertIn('yaml_config', cm.exception.error_dict)
# Pydantic errors usually mention the field and "field required" or similar
self.assertTrue(any("format" in error.message.lower() and "field required" in error.message.lower()
for error in cm.exception.error_dict['yaml_config']),
f"Error messages: {[e.message for e in cm.exception.error_dict['yaml_config']]}")
def test_import_profile_custom_validate_mappings_error_v1(self):
custom_validate_yaml = """
settings:
file_type: csv
importing: transactions # Importing transactions
mapping:
account_name: # This is an AccountNameMapping, not suitable for 'transactions' importing setting
target: account_name
source: AccName
"""
profile = ImportProfile(
name="Test Custom Validate Error V1",
yaml_config=custom_validate_yaml,
version=ImportProfile.Versions.VERSION_1
)
with self.assertRaises(ValidationError) as cm:
profile.full_clean()
self.assertIn('yaml_config', cm.exception.error_dict)
# Check for the specific message raised by custom_validate_mappings
# The message is "Mapping type AccountNameMapping not allowed for importing 'transactions'."
self.assertTrue(any("mapping type accountnamemapping not allowed for importing 'transactions'" in error.message.lower()
for error in cm.exception.error_dict['yaml_config']),
f"Error messages: {[e.message for e in cm.exception.error_dict['yaml_config']]}")
def test_import_profile_name_unique(self):
valid_yaml_config = """
settings:
file_type: csv
importing: transactions
mapping:
date:
target: date
source: Date
format: '%Y-%m-%d'
""" # Minimal valid YAML for this test
ImportProfile.objects.create(
name="Unique Name Test",
yaml_config=valid_yaml_config,
version=ImportProfile.Versions.VERSION_1
)
profile2 = ImportProfile(
name="Unique Name Test", # Same name
yaml_config=valid_yaml_config,
version=ImportProfile.Versions.VERSION_1
)
# full_clean should catch this because of the unique constraint on the model field.
# Django's Model.full_clean() calls Model.validate_unique().
with self.assertRaises(ValidationError) as cm:
profile2.full_clean()
self.assertIn('name', cm.exception.error_dict)
self.assertTrue(any("already exists" in error.message.lower() for error in cm.exception.error_dict['name']))
# As a fallback, or for more direct DB constraint testing, also test IntegrityError on save if full_clean didn't catch it.
# This will only be reached if the full_clean() above somehow passes.
# try:
# profile2.save()
# except IntegrityError:
# pass # Expected if full_clean didn't catch it
# else:
# if 'name' not in cm.exception.error_dict: # If full_clean passed and save also passed
# self.fail("IntegrityError not raised for duplicate name on save(), and full_clean() didn't catch it.")
def test_import_profile_form_valid_data(self):
valid_yaml_config = """
settings:
file_type: csv
delimiter: ','
encoding: utf-8
skip_lines: 0
trigger_transaction_rules: true
importing: transactions
mapping:
date:
target: date
source: Transaction Date
format: '%Y-%m-%d'
amount:
target: amount
source: Amount
description:
target: description
source: Narrative
account:
target: account
source: Account Name
type: name
type:
target: type
source: Credit Debit
detection_method: sign
is_paid:
target: is_paid
detection_method: always_paid
deduplication: []
"""
form_data = {
'name': 'Form Test Valid',
'yaml_config': valid_yaml_config,
'version': ImportProfile.Versions.VERSION_1
}
form = ImportProfileForm(data=form_data)
self.assertTrue(form.is_valid(), f"Form errors: {form.errors.as_json()}")
profile = form.save()
self.assertIsNotNone(profile.pk)
self.assertEqual(profile.name, 'Form Test Valid')
# YAMLField might re-serialize the YAML, so direct string comparison might be brittle
# if spacing/ordering changes. However, for now, let's assume it's stored as provided or close enough.
# A more robust check would be to load both YAMLs and compare the resulting dicts.
self.assertEqual(profile.yaml_config.strip(), valid_yaml_config.strip())
self.assertEqual(profile.version, ImportProfile.Versions.VERSION_1)
def test_import_profile_form_invalid_yaml(self):
# Using a YAML that causes a schema validation error (missing 'format' for date mapping)
invalid_yaml_for_form = """
settings:
file_type: csv
importing: transactions
mapping:
date:
target: date
source: Transaction Date
"""
form_data = {
'name': 'Form Test Invalid',
'yaml_config': invalid_yaml_for_form,
'version': ImportProfile.Versions.VERSION_1
}
form = ImportProfileForm(data=form_data)
self.assertFalse(form.is_valid())
self.assertIn('yaml_config', form.errors)
# Check for a message indicating schema validation failure
self.assertTrue(any("field required" in error.lower() for error in form.errors['yaml_config']))
class ImportServiceTests(TestCase):
# ... (existing setUp and other test methods from previous task) ...
def setUp(self):
minimal_yaml_config = """
settings:
file_type: csv
importing: transactions
mapping:
description:
target: description
source: Desc
"""
self.profile = ImportProfile.objects.create(
name="Test Service Profile",
yaml_config=minimal_yaml_config,
version=ImportProfile.Versions.VERSION_1
)
self.import_run = ImportRun.objects.create(
profile=self.profile,
status=ImportRun.Status.PENDING
)
# self.service is initialized in each test to allow specific mapping_config
# or to re-initialize if service state changes (though it shouldn't for these private methods)
# Tests for _transform_value
def test_transform_value_replace(self):
service = ImportService(self.import_run)
mapping_config = version_1.ColumnMapping(target="description", source="Desc") # Basic mapping
mapping_config.transformations = [
version_1.ReplaceTransformationRule(type="replace", pattern="old", replacement="new")
]
transformed_value = service._transform_value("this is old text", mapping_config)
self.assertEqual(transformed_value, "this is new text")
def test_transform_value_date_format(self):
service = ImportService(self.import_run)
# DateFormatTransformationRule is typically part of a DateMapping, but testing transform directly
mapping_config = version_1.TransactionDateMapping(target="date", source="Date", format="%d/%m/%Y") # format is for final coercion
mapping_config.transformations = [
version_1.DateFormatTransformationRule(type="date_format", original_format="%Y-%m-%d", new_format="%d/%m/%Y")
]
transformed_value = service._transform_value("2023-01-15", mapping_config)
self.assertEqual(transformed_value, "15/01/2023")
def test_transform_value_regex_replace(self):
service = ImportService(self.import_run)
mapping_config = version_1.ColumnMapping(target="description", source="Desc")
mapping_config.transformations = [
version_1.ReplaceTransformationRule(type="regex", pattern=r"\\d+", replacement="NUM")
]
transformed_value = service._transform_value("abc123xyz456", mapping_config)
self.assertEqual(transformed_value, "abcNUMxyzNUM")
# Tests for _coerce_type
def test_coerce_type_string_to_decimal(self):
service = ImportService(self.import_run)
# TransactionAmountMapping has coerce_to="positive_decimal" by default
mapping_config = version_1.TransactionAmountMapping(target="amount", source="Amt")
coerced = service._coerce_type("123.45", mapping_config)
self.assertEqual(coerced, Decimal("123.45"))
coerced_neg = service._coerce_type("-123.45", mapping_config)
self.assertEqual(coerced_neg, Decimal("123.45")) # positive_decimal behavior
# Test with coerce_to="decimal"
mapping_config_decimal = version_1.TransactionAmountMapping(target="amount", source="Amt", coerce_to="decimal")
coerced_neg_decimal = service._coerce_type("-123.45", mapping_config_decimal)
self.assertEqual(coerced_neg_decimal, Decimal("-123.45"))
def test_coerce_type_string_to_date(self):
service = ImportService(self.import_run)
mapping_config = version_1.TransactionDateMapping(target="date", source="Dt", format="%Y-%m-%d")
coerced = service._coerce_type("2023-01-15", mapping_config)
self.assertEqual(coerced, date(2023, 1, 15))
def test_coerce_type_string_to_transaction_type_sign(self):
service = ImportService(self.import_run)
mapping_config = version_1.TransactionTypeMapping(target="type", source="TType", detection_method="sign")
self.assertEqual(service._coerce_type("100.00", mapping_config), Transaction.Type.INCOME)
self.assertEqual(service._coerce_type("-100.00", mapping_config), Transaction.Type.EXPENSE)
self.assertEqual(service._coerce_type("0.00", mapping_config), Transaction.Type.EXPENSE) # Sign detection treats 0 as expense
self.assertEqual(service._coerce_type("+200", mapping_config), Transaction.Type.INCOME)
def test_coerce_type_string_to_transaction_type_keywords(self):
service = ImportService(self.import_run)
mapping_config = version_1.TransactionTypeMapping(
target="type",
source="TType",
detection_method="keywords",
income_keywords=["credit", "dep"],
expense_keywords=["debit", "wdrl"]
)
self.assertEqual(service._coerce_type("Monthly Credit", mapping_config), Transaction.Type.INCOME)
self.assertEqual(service._coerce_type("ATM WDRL", mapping_config), Transaction.Type.EXPENSE)
self.assertIsNone(service._coerce_type("Unknown Type", mapping_config)) # No keyword match
@patch('apps.import_app.services.v1.os.remove')
def test_process_file_simple_csv_transactions(self, mock_os_remove):
simple_transactions_yaml = """
settings:
file_type: csv
importing: transactions
delimiter: ','
skip_lines: 0
mapping:
date: {target: date, source: Date, format: '%Y-%m-%d'}
amount: {target: amount, source: Amount}
description: {target: description, source: Description}
type: {target: type, source: Type, detection_method: always_income}
account: {target: account, source: AccountName, type: name}
"""
self.profile.yaml_config = simple_transactions_yaml
self.profile.save()
self.import_run.refresh_from_db() # Ensure import_run has the latest profile reference if needed
csv_content = "Date,Amount,Description,Type,AccountName\n2023-01-01,100.00,Test Deposit,INCOME,TestAcc"
temp_file_path = None
try:
# Ensure TEMP_DIR exists if ImportService relies on it being pre-existing
# For NamedTemporaryFile, dir just needs to be a valid directory path.
# If ImportService.TEMP_DIR is a class variable pointing to a specific path,
# it should be created or mocked if it doesn't exist by default.
# For simplicity, let's assume it exists or tempfile handles it gracefully.
# If ImportService.TEMP_DIR is not guaranteed, use default temp dir.
temp_dir = getattr(ImportService, 'TEMP_DIR', None)
if temp_dir and not os.path.exists(temp_dir):
os.makedirs(temp_dir, exist_ok=True)
with tempfile.NamedTemporaryFile(mode='w+', delete=False, dir=temp_dir, suffix='.csv', encoding='utf-8') as tmp_file:
tmp_file.write(csv_content)
temp_file_path = tmp_file.name
self.addCleanup(lambda: os.remove(temp_file_path) if temp_file_path and os.path.exists(temp_file_path) else None)
service = ImportService(self.import_run)
with patch.object(service, '_create_transaction') as mock_create_transaction:
service.process_file(temp_file_path)
self.import_run.refresh_from_db() # Refresh to get updated status and counts
self.assertEqual(self.import_run.status, ImportRun.Status.FINISHED)
self.assertEqual(self.import_run.total_rows, 1)
self.assertEqual(self.import_run.successful_rows, 1)
mock_create_transaction.assert_called_once()
# The first argument to _create_transaction is the row_data dictionary
args_dict = mock_create_transaction.call_args[0][0]
self.assertEqual(args_dict['date'], date(2023, 1, 1))
self.assertEqual(args_dict['amount'], Decimal('100.00'))
self.assertEqual(args_dict['description'], "Test Deposit")
self.assertEqual(args_dict['type'], Transaction.Type.INCOME)
# Account 'TestAcc' does not exist, so _map_row should resolve 'account' to None.
# This assumes the default behavior of AccountMapping(type='name') when an account is not found
# and creation of new accounts from mapping is not enabled/implemented in _map_row for this test.
self.assertIsNone(args_dict.get('account'),
"Account should be None as 'TestAcc' is not created in this test setup.")
mock_os_remove.assert_called_once_with(temp_file_path)
finally:
# This cleanup is now handled by self.addCleanup, but kept for safety if addCleanup fails early.
if temp_file_path and os.path.exists(temp_file_path) and not mock_os_remove.called:
# If mock_os_remove was not called (e.g., an error before service.process_file finished),
# we might need to manually clean up if addCleanup didn't register or run.
# However, addCleanup is generally robust.
pass