From 5de68f5979e5984eb218dd7fd28d3536428ffead Mon Sep 17 00:00:00 2001 From: Eg0ra Date: Thu, 3 Oct 2024 12:49:56 +0700 Subject: [PATCH] Add tests for IntermediateManyToMany field --- poetry.lock | 16 ++- pyproject.toml | 3 + .../migrations/0002_alter_artist_bands.py | 21 ++++ tests/fake_app/models.py | 6 +- tests/fake_app/resources.py | 27 ++++ tests/test_models/test_fields.py | 116 +++++++++++++++++- 6 files changed, 185 insertions(+), 4 deletions(-) create mode 100644 tests/fake_app/migrations/0002_alter_artist_bands.py diff --git a/poetry.lock b/poetry.lock index c2f0928..da6ee49 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1436,6 +1436,20 @@ pytest = ">=7.0.0" docs = ["sphinx", "sphinx-rtd-theme"] testing = ["Django", "django-configurations (>=2.0)"] +[[package]] +name = "pytest-lazy-fixtures" +version = "1.1.1" +description = "Allows you to use fixtures in @pytest.mark.parametrize." +optional = false +python-versions = "<4.0,>=3.8" +files = [ + {file = "pytest_lazy_fixtures-1.1.1-py3-none-any.whl", hash = "sha256:a4b396a361faf56c6305535fd0175ce82902ca7cf668c4d812a25ed2bcde8183"}, + {file = "pytest_lazy_fixtures-1.1.1.tar.gz", hash = "sha256:0c561f0d29eea5b55cf29b9264a3241999ffdb74c6b6e8c4ccc0bd2c934d01ed"}, +] + +[package.dependencies] +pytest = ">=7" + [[package]] name = "pytest-mock" version = "3.14.0" @@ -2165,4 +2179,4 @@ files = [ [metadata] lock-version = "2.0" python-versions = "^3.10" -content-hash = "6ea8a70ab3c28004709f47ff915bf96faad32a96da84bfa15633b99f800969ee" +content-hash = "747e35559a6082812fa5373ee0437dd64f1c6f3de1ff805a3413f3291bb46b84" diff --git a/pyproject.toml b/pyproject.toml index 1662c6f..e4b1196 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -87,6 +87,9 @@ pytest-django = "^4.9.0" # This plugin provides a mocker fixture for pytest # https://pypi.org/project/pytest-mock/ pytest-mock = "^3.14.0" +# Allows you to use fixtures in @pytest.mark.parametrize. +# https://pypi.org/project/pytest-lazy-fixtures/ +pytest-lazy-fixtures = "^1.1.1" # Package for generating test data # https://factoryboy.readthedocs.io/en/stable/ factory-boy = "^3.3.1" diff --git a/tests/fake_app/migrations/0002_alter_artist_bands.py b/tests/fake_app/migrations/0002_alter_artist_bands.py new file mode 100644 index 0000000..44056e9 --- /dev/null +++ b/tests/fake_app/migrations/0002_alter_artist_bands.py @@ -0,0 +1,21 @@ +# Generated by Django 5.1.1 on 2024-10-02 07:26 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("fake_app", "0001_initial"), + ] + + operations = [ + migrations.AlterField( + model_name="artist", + name="bands", + field=models.ManyToManyField( + related_name="artists", + through="fake_app.Membership", + to="fake_app.band", + ), + ), + ] diff --git a/tests/fake_app/models.py b/tests/fake_app/models.py index ef4b1e2..29a1855 100644 --- a/tests/fake_app/models.py +++ b/tests/fake_app/models.py @@ -20,7 +20,11 @@ class Artist(models.Model): """Model representing artist.""" name = models.CharField(max_length=100, unique=True) - bands = models.ManyToManyField("Band", through="Membership") + bands = models.ManyToManyField( + "Band", + through="Membership", + related_name="artists", + ) instrument = models.ForeignKey( Instrument, diff --git a/tests/fake_app/resources.py b/tests/fake_app/resources.py index 7d861ac..12d2b5c 100644 --- a/tests/fake_app/resources.py +++ b/tests/fake_app/resources.py @@ -46,3 +46,30 @@ def get_queryset(self): "membership_set__band", "bands", ) + + +class BandResourceWithM2M(CeleryModelResource): + """Band resource with Many2Many field.""" + + artists = IntermediateManyToManyField( + attribute="artists", + column_name="Artists in band", + widget=IntermediateManyToManyWidget( + rem_model=Artist, + rem_field="name", + extra_fields=["date_joined"], + instance_separator=";", + ), + ) + + class Meta: + model = Band + clean_model_instances = True + fields = ["id", "title", "artists"] + + def get_queryset(self): + """Return a queryset.""" + return Band.objects.all().prefetch_related( + "membership_set__artist", + "artists", + ) diff --git a/tests/test_models/test_fields.py b/tests/test_models/test_fields.py index 8a489e5..8a0d2ae 100644 --- a/tests/test_models/test_fields.py +++ b/tests/test_models/test_fields.py @@ -1,9 +1,14 @@ +from django.db import models + +import pytest +import pytest_mock +from pytest_lazy_fixtures import lf from pytest_mock import MockerFixture from import_export_extensions.fields import IntermediateManyToManyField from ..fake_app import factories -from ..fake_app.models import Artist, Membership +from ..fake_app.models import Artist, Band, Instrument, Membership def test_save_method(existing_artist: Artist, mocker: MockerFixture): @@ -45,10 +50,50 @@ def test_save_method(existing_artist: Artist, mocker: MockerFixture): assert existing_artist.bands.count() == 2 -def test_save_readonly_field(existing_artist: Artist): +def test_save_readonly_field(existing_artist: Artist, band: Band): """Simple test to check that readonly field changes nothing.""" + factories.MembershipFactory(artist=existing_artist, band=band) field = IntermediateManyToManyField(attribute="bands", readonly=True) field.save(existing_artist, {}) + assert existing_artist.bands.all() + + +def test_save_with_exception_during_full_clean( + existing_artist: models.Model, + band: Band, + mocker: pytest_mock.MockerFixture, +): + """Check that the exception was handled during full_clean.""" + wrong_format_data = "wrong_date_format" + column_name = "Bands" + + # suggest widget returned following data + mocker.patch( + target=( + "import_export_extensions.fields.IntermediateManyToManyField.clean" + ), + return_value=[ + { + "object": band, + "properties": { + "date_joined": wrong_format_data, + }, + }, + ], + ) + + intermediate_field = IntermediateManyToManyField( + attribute="bands", + column_name=column_name, + ) + with pytest.raises( + ValueError, + match=( + f"Column '{column_name}':.*{wrong_format_data}.*value has an " + "invalid date format. It must be in YYYY-MM-DD format." + ), + ): + intermediate_field.save(existing_artist, {}) def test_get_value(existing_artist: Artist): @@ -71,3 +116,70 @@ def test_get_value_none_attribute(existing_artist: Artist): """There should be no error if ``attribute`` not set.""" field = IntermediateManyToManyField() assert field.get_value(existing_artist) is None + + +@pytest.mark.parametrize( + argnames=["obj", "attribute", "expected_field_params"], + argvalues=[ + pytest.param( + lf("existing_artist"), + "bands", + ("artist", "band"), + id="Object with forward relation", + ), + pytest.param( + lf("band"), + "artists", + ("band", "artist"), + id="Object with reversed relation", + ), + ], +) +def test_get_relation_field_params( + obj: models.Model, + attribute: str, + expected_field_params: tuple[str, str], +): + """Test that method returns correct relation field params.""" + intermediate_field = IntermediateManyToManyField(attribute) + m2m_rel, field_name, reversed_field_name = ( + intermediate_field.get_relation_field_params(obj) + ) + assert m2m_rel.through == Membership + assert (field_name, reversed_field_name) == expected_field_params + + +def test_get_through_model_accessor_name(existing_artist: models.Model): + """Test that method returns correct accessor_name.""" + expected_accessor_name = "membership_set" + attribute = "bands" + intermediate_field = IntermediateManyToManyField(attribute) + accessor_name = intermediate_field.get_through_model_accessor_name( + existing_artist, + existing_artist._meta.get_field(attribute).remote_field, + ) + assert accessor_name == expected_accessor_name + + +def test_get_through_model_accessor_name_with_wrong_relation( + existing_artist: models.Model, + mocker: pytest_mock.MockerFixture, +): + """Check that method raise error if m2m relation does not exists.""" + attribute = "bands" + m2m_relation = existing_artist._meta.get_field(attribute).remote_field + mocker.patch.object( + target=m2m_relation, + attribute="through", + new=Instrument, + ) + + intermediate_field = IntermediateManyToManyField(attribute) + with pytest.raises( + ValueError, + match=f"{Artist} has no relation with {Instrument}", + ): + intermediate_field.get_through_model_accessor_name( + existing_artist, + m2m_relation, + )