diff --git a/fastapi_startkit/tests/masoniteorm/relationships/__init__.py b/fastapi_startkit/tests/masoniteorm/relationships/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/fastapi_startkit/tests/masoniteorm/relationships/conftest.py b/fastapi_startkit/tests/masoniteorm/relationships/conftest.py new file mode 100644 index 00000000..ada30fe7 --- /dev/null +++ b/fastapi_startkit/tests/masoniteorm/relationships/conftest.py @@ -0,0 +1,54 @@ +"""Shared test doubles for the relationship unit tests. + +``make_builder`` returns a chainable mock standing in for masoniteorm's async +``QueryBuilder``. The relationship classes are unit-tested against this mock +rather than real sqlite because they are not currently wired to the fork's async +``QueryBuilder``: + +* the builder (``masoniteorm/models/builder.py``) lacks ``table()``, + ``without_global_scopes()`` and ``add_select()``, all called by + ``BelongsToMany``; +* ``BaseRelationship.get_builder()`` calls ``self.fn()`` with no args against + ``BelongsToMany``'s ``lambda x:`` factory, raising ``TypeError``; +* ``attach``/``detach`` chain those same missing methods on a plain ``Pivot`` + model. + +The ``CHAINABLE`` list below mirrors exactly the builder methods the relationship +code expects, so the relationship logic can be tested in isolation. Real-DB +wiring is tracked in the project backlog. +""" + +from unittest.mock import AsyncMock, MagicMock + + +CHAINABLE = [ + "select", + "add_select", + "join", + "table", + "where", + "where_in", + "where_column", + "run_scopes", + "without_global_scopes", + "when", + "new", + "with_", +] + + +def make_builder(table_name="others", get_result=None, columns=None, connection="sqlite"): + """A chainable query-builder mock. + + Every query-shaping method returns the same mock so call chains resolve, + while ``get`` is awaitable and ``get_table_name`` is fixed. + """ + builder = MagicMock(name=f"builder<{table_name}>") + builder.get_table_name.return_value = table_name + builder._columns = columns + builder.connection = connection + builder.connection_name = connection + for method in CHAINABLE: + getattr(builder, method).return_value = builder + builder.get = AsyncMock(return_value=[] if get_result is None else get_result) + return builder diff --git a/fastapi_startkit/tests/masoniteorm/relationships/test_base_relationship.py b/fastapi_startkit/tests/masoniteorm/relationships/test_base_relationship.py new file mode 100644 index 00000000..0f51507f --- /dev/null +++ b/fastapi_startkit/tests/masoniteorm/relationships/test_base_relationship.py @@ -0,0 +1,137 @@ +"""Unit tests for the abstract ``BaseRelationship`` contract. + +These tests assert ``BaseRelationship``'s key handling and that its abstract +hooks raise ``NotImplementedError``. The join-clause test mocks the query builder +(see ``conftest.make_builder``) because this fork's async ``QueryBuilder`` is not +wired for the relationship builder chain (it lacks ``table()`` / +``without_global_scopes()`` / ``add_select()``); this is documented for the whole +relationships suite in ``conftest.py``. +""" + +from unittest.mock import MagicMock + +import pytest + +from fastapi_startkit.masoniteorm.models import registry +from fastapi_startkit.masoniteorm.relationships.BaseRelationship import BaseRelationship + +from .conftest import make_builder + + +def test_rejects_non_string_relationship_name(): + with pytest.raises(TypeError, match="expects a string"): + BaseRelationship(["not", "a", "string"]) + + +def test_stores_keys_and_defaults(): + rel = BaseRelationship("Profile", local_key="id", foreign_key="user_id") + + assert rel.local_key == "id" + assert rel.foreign_key == "user_id" + assert rel.attribute is None + + +def test_set_name_records_attribute_and_owner(): + rel = BaseRelationship("Profile") + + class Owner: + pass + + rel.__set_name__(Owner, "profile") + + assert rel.attribute == "profile" + assert rel.owner_class is Owner + + +def test_get_builder_resolves_through_registry(monkeypatch): + related_builder = object() + related_instance = MagicMock() + related_instance.get_builder.return_value = related_builder + related_model = MagicMock(return_value=related_instance) + + monkeypatch.setattr(registry.Registry, "resolve", classmethod(lambda cls, name: related_model)) + + rel = BaseRelationship("Profile") + + assert rel.get_builder() is related_builder + + +def test_get_returns_self_when_accessed_on_class(): + rel = BaseRelationship("Profile") + + assert rel.__get__(None, object) is rel + + +def test_get_returns_self_when_owner_not_loaded(): + rel = BaseRelationship("Profile") + rel.attribute = "profile" + rel.set_keys = MagicMock() + + instance = MagicMock() + instance.is_loaded.return_value = False + + assert rel.__get__(instance, object) is rel + rel.set_keys.assert_called_once_with(instance, "profile") + + +def test_get_returns_cached_relationship(): + rel = BaseRelationship("Profile") + rel.attribute = "profile" + rel.set_keys = MagicMock() + + instance = MagicMock() + instance.is_loaded.return_value = True + instance._relationships = {"profile": "cached"} + + assert rel.__get__(instance, object) == "cached" + + +def test_getattr_delegates_to_builder(): + builder = make_builder() + builder.custom_attr = "delegated" + + rel = BaseRelationship("Profile") + rel.get_builder = MagicMock(return_value=builder) + + assert rel.custom_attr == "delegated" + + +def test_joins_builds_join_clause(): + other = make_builder(table_name="profiles") + local = make_builder(table_name="users") + + rel = BaseRelationship("Profile", local_key="id", foreign_key="user_id") + rel.get_builder = MagicMock(return_value=other) + + rel.joins(local, clause="left") + + local.join.assert_called_once_with( + "profiles", + "users.id", + "=", + "profiles.user_id", + clause="left", + ) + + +@pytest.mark.parametrize( + "method,args", + [ + ("apply_query", (MagicMock(), MagicMock())), + ("query_where_exists", (MagicMock(), MagicMock())), + ("get_with_count_query", (MagicMock(), MagicMock())), + ("attach", (MagicMock(), MagicMock())), + ("get_related", (MagicMock(), MagicMock())), + ("relate", (MagicMock(),)), + ("detach", (MagicMock(), MagicMock())), + ("attach_related", (MagicMock(), MagicMock())), + ("detach_related", (MagicMock(), MagicMock())), + ("query_has", (MagicMock(),)), + ("map_related", (MagicMock(),)), + ], +) +def test_abstract_methods_raise_not_implemented(method, args): + rel = BaseRelationship("Profile") + + with pytest.raises(NotImplementedError): + getattr(rel, method)(*args) diff --git a/fastapi_startkit/tests/masoniteorm/relationships/test_belongs_to_many.py b/fastapi_startkit/tests/masoniteorm/relationships/test_belongs_to_many.py new file mode 100644 index 00000000..ed29d515 --- /dev/null +++ b/fastapi_startkit/tests/masoniteorm/relationships/test_belongs_to_many.py @@ -0,0 +1,383 @@ +"""Unit tests for :class:`BelongsToMany`, run against a mocked query builder. + +These tests mock the query builder (see ``conftest.make_builder``) instead of +running against real sqlite because ``BelongsToMany`` is not currently wired to +this fork's async ``QueryBuilder``. A real many-to-many fixture already exists +(``Store``/``Product`` + the ``product_store`` pivot in ``fixtures/migration.py``), +so the blocker is framework wiring, not test data: + +* The async ``QueryBuilder`` (``masoniteorm/models/builder.py``) does not implement + ``table()``, ``without_global_scopes()`` or ``add_select()``, all of which + ``BelongsToMany`` calls (e.g. ``BelongsToMany.py`` lines 78, 227, 463). Real + access/eager-load/``where_has`` raise ``AttributeError``. +* ``BaseRelationship.get_builder()`` calls ``self.fn()`` with no arguments while + ``BelongsToMany.__init__`` assigns ``self.fn = lambda x: ...``, so resolving the + related builder raises ``TypeError: () missing 1 required positional + argument: 'x'``. +* ``attach``/``detach`` chain ``Pivot.on(...).table(...).without_global_scopes() + .create(...)`` on a plain ``Pivot`` model that has none of those chainable + builder methods, so the chain resolves to ``None`` and raises ``TypeError``. + +Mocking the builder lets these tests exercise the relationship's own logic (key +inference, pivot hydration, join/select construction) in isolation. Wiring the +async ``QueryBuilder`` for real-DB coverage is tracked in the project backlog. +""" + +from unittest.mock import MagicMock, patch + +import pytest + +from fastapi_startkit.masoniteorm.collection import Collection +from fastapi_startkit.masoniteorm.models import registry +from fastapi_startkit.masoniteorm.relationships.BelongsToMany import BelongsToMany + +from .conftest import make_builder + + +def make_pivot_model(extra=None): + model = MagicMock() + model.store_product_id = 1 + model.m_reserved2 = 2 + model.m_reserved3 = 3 + model.m_reserved4 = "u" + model.m_reserved5 = "c" + if extra is not None: + model.extra = extra + model.__original_attributes__ = MagicMock() + return model + + +def make_relationship(table="store_product", **kwargs): + rel = BelongsToMany( + "Product", + local_foreign_key="store_id", + other_foreign_key="product_id", + local_owner_key="id", + other_owner_key="id", + table=table, + **kwargs, + ) + return rel + + +def test_init_resolves_string_name(monkeypatch): + monkeypatch.setattr(registry.Registry, "resolve", classmethod(lambda cls, name: "resolved")) + rel = BelongsToMany("Product") + + assert rel.fn("x") == "resolved" + assert rel.local_owner_key == "id" + assert rel.other_owner_key == "id" + + +def test_set_keys_applies_defaults(): + rel = BelongsToMany("Product") + rel.local_key = None + rel.foreign_key = None + + rel.set_keys(MagicMock(), "product") + + assert rel.local_key == "id" + assert rel.foreign_key == "product_id" + + +def test_table_setter_is_chainable(): + rel = make_relationship() + + assert rel.table("custom_pivot") is rel + assert rel._table == "custom_pivot" + + +def test_map_related_returns_input(): + rel = make_relationship() + payload = object() + + assert rel.map_related(payload) is payload + + +def test_get_pivot_table_name_sorts_and_singularizes(): + rel = make_relationship() + query = make_builder(table_name="stores") + builder = make_builder(table_name="products") + + assert rel.get_pivot_table_name(query, builder) == "product_store" + + +def test_make_builder_applies_eager_loads(): + rel = make_relationship() + query = make_builder() + rel.get_builder = MagicMock(return_value=query) + + result = rel.make_builder(["images"]) + + query.with_.assert_called_once_with(["images"]) + assert result is query + + +def test_register_related_adds_scoped_collection(): + rel = make_relationship() + rel._table = "store_product" + + model = MagicMock() + model.id = 5 + collection = MagicMock() + collection.where.return_value = "scoped" + + rel.register_related("products", model, collection) + + collection.where.assert_called_once_with("store_product_id", 5) + model.add_relation.assert_called_once_with({"products": "scoped"}) + + +def test_joins_builds_inner_and_outer_joins(): + rel = make_relationship() + query = make_builder(table_name="products") + rel.get_builder = MagicMock(return_value=query) + + builder = make_builder(table_name="stores", columns=None) + rel.joins(builder, clause="left") + + # No pre-existing columns → select clause added, then two joins. + assert builder.select.called + assert builder.join.call_count == 2 + + +def test_query_has_delegates_to_builder_method(): + rel = make_relationship() + query = make_builder(table_name="products") + rel.get_builder = MagicMock(return_value=query) + + builder = make_builder(table_name="stores") + rel.query_has(builder, method="where_exists") + + builder.where_exists.assert_called_once() + + +def test_query_where_exists_invokes_callback_and_method(): + rel = make_relationship() + query = make_builder(table_name="products") + rel.get_builder = MagicMock(return_value=query) + + builder = make_builder(table_name="stores") + callback = MagicMock(return_value="sub") + + rel.query_where_exists(builder, callback, method="where_exists") + + callback.assert_called_once() + builder.where_exists.assert_called_once() + + +def test_get_with_count_query_adds_select(): + rel = make_relationship() + query = make_builder(table_name="products") + rel.get_builder = MagicMock(return_value=query) + + builder = make_builder(table_name="stores", columns=["*"]) + rel.get_with_count_query(builder, callback=None) + + builder.add_select.assert_called_once() + + +def test_get_with_count_query_selects_star_when_no_columns(): + rel = make_relationship() + query = make_builder(table_name="products") + rel.get_builder = MagicMock(return_value=query) + + builder = make_builder(table_name="stores", columns=[]) + rel.get_with_count_query(builder, callback=None) + + builder.select.assert_called_once_with("*") + + +@patch("fastapi_startkit.masoniteorm.relationships.BelongsToMany.Pivot", autospec=True) +def test_attach_creates_pivot_record(pivot): + rel = make_relationship() + chain = pivot.on.return_value.table.return_value.without_global_scopes.return_value + + current = MagicMock() + current.id = 1 + current.get_builder.return_value.connection = "sqlite" + related = MagicMock() + related.id = 9 + + rel.attach(current, related) + + chain.create.assert_called_once_with({"store_id": 1, "product_id": 9}) + + +@patch("fastapi_startkit.masoniteorm.relationships.BelongsToMany.Pivot", autospec=True) +def test_attach_includes_timestamps(pivot): + rel = make_relationship(with_timestamps=True) + chain = pivot.on.return_value.table.return_value.without_global_scopes.return_value + + current = MagicMock() + current.id = 1 + current.get_builder.return_value.connection = "sqlite" + related = MagicMock() + related.id = 9 + + rel.attach(current, related) + + data = chain.create.call_args.args[0] + assert "created_at" in data and "updated_at" in data + + +@patch("fastapi_startkit.masoniteorm.relationships.BelongsToMany.Pivot", autospec=True) +def test_detach_deletes_pivot_record(pivot): + rel = make_relationship() + chain = pivot.on.return_value.table.return_value.without_global_scopes.return_value.where.return_value + + current = MagicMock() + current.id = 1 + current.get_builder.return_value.connection = "sqlite" + related = MagicMock() + related.id = 9 + + rel.detach(current, related) + + chain.delete.assert_called_once() + + +# ``attach_related`` calls ``Pivot.table(...)`` first, but ``Pivot`` exposes no +# ``table`` classmethod (only the instance chain ``Pivot.on(...).table(...)`` does). +# The autospec mock surfaces this signature drift as an ``AttributeError``; the +# bare-MagicMock version silently masked it. Framework fix tracked in backlog #738. +@pytest.mark.xfail( + raises=AttributeError, + strict=True, + reason="Pivot has no table() classmethod; attach_related() drift, backlog #738", +) +@patch("fastapi_startkit.masoniteorm.relationships.BelongsToMany.Pivot", autospec=True) +def test_attach_related_creates_pivot_record(pivot): + rel = make_relationship() + chain = pivot.table.return_value.on.return_value.without_global_scopes.return_value + + current = MagicMock() + current.id = 2 + current.get_builder.return_value.connection_name = "sqlite" + related = MagicMock() + related.id = 7 + + rel.attach_related(current, related) + + chain.create.assert_called_once_with({"store_id": 2, "product_id": 7}) + + +@patch("fastapi_startkit.masoniteorm.relationships.BelongsToMany.Pivot", autospec=True) +def test_detach_related_deletes_pivot_record(pivot): + rel = make_relationship() + chain = pivot.on.return_value.table.return_value.without_global_scopes.return_value.where.return_value + + current = MagicMock() + current.id = 2 + current.get_builder.return_value.connection_name = "sqlite" + related = MagicMock() + related.id = 7 + + rel.detach_related(current, related) + + chain.delete.assert_called_once() + + +def test_relate_builds_query_with_joins_and_where(): + rel = make_relationship() + query = make_builder(table_name="products") + rel.get_builder = MagicMock(return_value=query) + + owner_builder = make_builder(table_name="stores") + + class Related: + id = 3 + + def get_builder(self_inner): + return owner_builder + + result = rel.relate(Related()) + + assert query.join.call_count == 2 + assert result is query + + +async def test_make_query_for_single_relation(): + rel = make_relationship() + fetched = [] + query_builder = make_builder(table_name="products", get_result=fetched) + rel.get_builder = MagicMock(return_value=query_builder) + + owner_query = make_builder(table_name="stores") + + class Relation: + id = 4 + + out = await rel.make_query(owner_query, Relation()) + + assert out is fetched + query_builder.get.assert_awaited_once() + + +async def test_get_related_hydrates_pivot(monkeypatch): + rel = make_relationship(with_timestamps=True) + model = make_pivot_model() + fetched = [model] + + query_builder = make_builder(table_name="products", get_result=fetched) + rel.get_builder = MagicMock(return_value=query_builder) + + with patch("fastapi_startkit.masoniteorm.relationships.BelongsToMany.Pivot", autospec=True): + owner_query = make_builder(table_name="stores") + + class Relation: + id = 4 + + out = await rel.get_related(owner_query, Relation()) + + assert out is fetched + model.__original_attributes__.update.assert_called_once() + + +async def test_make_query_infers_pivot_table_and_keys(): + rel = BelongsToMany("Product", local_owner_key="id", other_owner_key="id") + rel.local_key = None + rel.foreign_key = None + + query_builder = make_builder(table_name="products", get_result=[]) + rel.get_builder = MagicMock(return_value=query_builder) + owner_query = make_builder(table_name="stores") + + await rel.make_query(owner_query, Collection([])) + + assert rel._table == "product_store" + assert rel.foreign_key == "product_id" + assert rel.local_key == "store_id" + + +async def test_apply_query_hydrates_with_timestamps_and_fields(): + rel = make_relationship(with_timestamps=True, with_fields=["extra"]) + model = make_pivot_model(extra="x") + fetched = [model] + + query = make_builder(table_name="products", get_result=fetched) + owner = MagicMock() + owner.get_table_name.return_value = "stores" + owner.id = 5 + + with patch("fastapi_startkit.masoniteorm.relationships.BelongsToMany.Pivot", autospec=True): + out = await rel.apply_query(query, owner) + + assert out is fetched + model.__original_attributes__.update.assert_called_once() + model.delete_attribute.assert_any_call("m_reserved2") + + +async def test_apply_query_infers_pivot_table_when_missing(): + rel = BelongsToMany("Product", local_owner_key="id", other_owner_key="id") + rel.local_key = None + rel.foreign_key = None + + query = make_builder(table_name="products", get_result=[]) + owner = MagicMock() + owner.get_table_name.return_value = "stores" + + with patch("fastapi_startkit.masoniteorm.relationships.BelongsToMany.Pivot", autospec=True): + await rel.apply_query(query, owner) + + assert rel._table == "product_store" diff --git a/fastapi_startkit/tests/masoniteorm/relationships/test_morph_relations.py b/fastapi_startkit/tests/masoniteorm/relationships/test_morph_relations.py new file mode 100644 index 00000000..c34e9b9c --- /dev/null +++ b/fastapi_startkit/tests/masoniteorm/relationships/test_morph_relations.py @@ -0,0 +1,443 @@ +"""Unit tests for the polymorphic relationships (MorphOne/MorphMany/MorphToMany/MorphTo). + +``MorphOne`` and ``MorphToMany`` are tested with mocks because their +``morph_map()`` implementations reference an undefined ``load_config`` name +(``MorphOne.py`` line 134, ``MorphToMany.py`` line 103) and raise ``NameError`` +when called for real. ``MorphMany`` is likewise mocked: its resolved builder is +not wired to this fork's async ``QueryBuilder``. ``MorphTo`` does work against +real sqlite and is additionally covered end-to-end in +``sqlite/relationships/test_sqlite_polymorphic.py``. The framework fixes needed +for real-DB coverage of the other morphs are tracked in the project backlog. +""" + +from unittest.mock import MagicMock + +import pytest + +from fastapi_startkit.masoniteorm.collection import Collection +from fastapi_startkit.masoniteorm.models import registry +from fastapi_startkit.masoniteorm.relationships.MorphOne import MorphOne +from fastapi_startkit.masoniteorm.relationships.MorphMany import MorphMany +from fastapi_startkit.masoniteorm.relationships.MorphToMany import MorphToMany +from fastapi_startkit.masoniteorm.relationships.MorphTo import MorphTo + + +class Article: + __primary_key__ = "id" + + def __init__(self, pk=1): + self.id = pk + self.record_type = "article" + self.record_id = pk + self._pk = pk + + def serialize(self): + return {"id": self._pk, "record_type": "article", "record_id": self._pk} + + def get_primary_key(self): + return "id" + + def get_primary_key_value(self): + return self._pk + + +def morph_builder(table_name="likes"): + builder = MagicMock(name="polymorphic_builder") + for method in ("where", "where_in"): + getattr(builder, method).return_value = builder + builder.get.return_value = "GET_RESULT" + builder.first.return_value = "FIRST_RESULT" + builder.get_table_name.return_value = table_name + return builder + + +# --------------------------------------------------------------------------- # +# MorphMany +# --------------------------------------------------------------------------- # +def test_morph_many_init_and_set_keys(): + rel = MorphMany(lambda self: None, morph_key=None, morph_id=None) + rel.set_keys(None, None) + + assert rel.morph_id == "record_id" + assert rel.morph_key == "record_type" + + +def test_morph_many_get_record_key_lookup(): + rel = MorphMany(lambda self: None) + rel.morph_map = lambda: {"article": Article} + + assert rel.get_record_key_lookup(Article()) == "article" + + +def test_morph_many_get_record_key_lookup_raises_when_unknown(): + rel = MorphMany(lambda self: None) + rel.morph_map = lambda: {} + + with pytest.raises(ValueError, match="record type key"): + rel.get_record_key_lookup(Article()) + + +def test_morph_many_apply_query_filters_by_type_and_id(): + rel = MorphMany(lambda self: None) + rel.morph_map = lambda: {"article": Article} + rel.polymorphic_builder = morph_builder() + + owner_builder = MagicMock() + owner_builder._model = Article() + instance = MagicMock() + instance.get_primary_key_value.return_value = 7 + + result = rel.apply_query(owner_builder, instance) + + assert result == "GET_RESULT" + rel.polymorphic_builder.where.assert_any_call("record_type", "article") + + +def test_morph_many_get_related_single(): + rel = MorphMany(lambda self: None) + rel.morph_map = lambda: {"article": Article} + rel.polymorphic_builder = morph_builder() + + result = rel.get_related(None, Article(3)) + + builder = rel.polymorphic_builder + assert result == "GET_RESULT" + builder.where.assert_any_call("record_type", "article") + builder.where.assert_any_call("record_id", 3) + builder.get.assert_called_once() + + +def test_morph_many_get_related_single_with_callback(): + rel = MorphMany(lambda self: None) + rel.morph_map = lambda: {"article": Article} + rel.polymorphic_builder = morph_builder() + callback = MagicMock(return_value=morph_builder()) + + result = rel.get_related(None, Article(3), callback=callback) + + builder = rel.polymorphic_builder + # The callback receives the fully-filtered builder, not a bare mock. + callback.assert_called_once_with(builder) + builder.where.assert_any_call("record_type", "article") + builder.where.assert_any_call("record_id", 3) + assert result == "GET_RESULT" + + +def test_morph_many_get_related_collection(): + rel = MorphMany(lambda self: None) + rel.morph_map = lambda: {"article": Article} + rel.polymorphic_builder = morph_builder() + + relation = Collection([Article(1), Article(2)]) + result = rel.get_related(None, relation) + + builder = rel.polymorphic_builder + assert result == "GET_RESULT" + builder.where.assert_called_once_with("likes.record_type", "article") + where_in_args = builder.where_in.call_args.args + assert where_in_args[0] == "record_id" + assert list(where_in_args[1]) == [1, 2] + builder.get.assert_called_once() + + +def test_morph_many_register_related(): + rel = MorphMany(lambda self: None) + rel.morph_map = lambda: {"article": Article} + + collection = MagicMock() + collection.where.return_value = collection + + model = MagicMock() + model.__class__ = Article + model.get_primary_key_value.return_value = 5 + + rel.register_related("likes", model, collection) + + model.add_relation.assert_called_once_with({"likes": collection}) + + +def test_morph_many_morph_map_reads_registry(monkeypatch): + monkeypatch.setattr(registry.Registry, "get_morph_map", classmethod(lambda cls: {"article": Article})) + rel = MorphMany(lambda self: None) + + assert rel.morph_map() == {"article": Article} + + +# --------------------------------------------------------------------------- # +# MorphOne +# --------------------------------------------------------------------------- # +def test_morph_one_init_with_string_shifts_keys(): + rel = MorphOne("likeable_type", "likeable_id") + + assert rel.fn is None + assert rel.morph_key == "likeable_type" + assert rel.morph_id == "likeable_id" + + +def test_morph_one_init_with_callable_keeps_defaults(): + rel = MorphOne(lambda self: None) + + assert rel.morph_key == "record_type" + assert rel.morph_id == "record_id" + + +def test_morph_one_apply_query_returns_first(): + rel = MorphOne(lambda self: None) + rel.morph_map = lambda: {"article": Article} + rel.polymorphic_builder = morph_builder() + + owner_builder = MagicMock() + owner_builder._model = Article() + instance = MagicMock() + instance.get_primary_key_value.return_value = 2 + + result = rel.apply_query(owner_builder, instance) + + builder = rel.polymorphic_builder + assert result == "FIRST_RESULT" + builder.where.assert_any_call("record_type", "article") + builder.where.assert_any_call("record_id", 2) + # MorphOne resolves to a single row: .first(), never .get(). + builder.first.assert_called_once() + builder.get.assert_not_called() + + +def test_morph_one_get_related_single(): + rel = MorphOne(lambda self: None) + rel.morph_map = lambda: {"article": Article} + rel.polymorphic_builder = morph_builder() + + result = rel.get_related(None, Article(4)) + + builder = rel.polymorphic_builder + assert result == "FIRST_RESULT" + builder.where.assert_any_call("record_type", "article") + builder.where.assert_any_call("record_id", 4) + builder.first.assert_called_once() + builder.get.assert_not_called() + + +def test_morph_one_get_related_collection(): + rel = MorphOne(lambda self: None) + rel.morph_map = lambda: {"article": Article} + rel.polymorphic_builder = morph_builder() + + relation = Collection([Article(1), Article(2)]) + result = rel.get_related(None, relation) + + builder = rel.polymorphic_builder + assert result == "GET_RESULT" + builder.where.assert_called_once_with("likes.record_type", "article") + where_in_args = builder.where_in.call_args.args + assert where_in_args[0] == "record_id" + assert list(where_in_args[1]) == [1, 2] + builder.get.assert_called_once() + + +def test_morph_one_register_related_uses_first(): + rel = MorphOne(lambda self: None) + rel.morph_map = lambda: {"article": Article} + + collection = MagicMock() + collection.where.return_value = collection + collection.first.return_value = "related" + + model = MagicMock() + model.__class__ = Article + model.get_primary_key_value.return_value = 9 + + rel.register_related("record", model, collection) + + model.add_relation.assert_called_once_with({"record": "related"}) + + +# --------------------------------------------------------------------------- # +# MorphToMany +# --------------------------------------------------------------------------- # +def test_morph_to_many_init_with_string(): + rel = MorphToMany("likeable_type", "likeable_id") + + assert rel.fn is None + assert rel.morph_key == "likeable_type" + assert rel.morph_id == "likeable_id" + + +def test_morph_to_many_set_keys_defaults(): + rel = MorphToMany(lambda self: None, morph_key=None, morph_id=None) + rel.set_keys(None, None) + + assert rel.morph_key == "record_type" + assert rel.morph_id == "record_id" + + +def test_morph_to_many_apply_query(): + model = MagicMock() + model.get_primary_key.return_value = "id" + model.where.return_value.first.return_value = "MODEL" + + rel = MorphToMany(lambda self: None) + rel.morph_map = lambda: {"article": model} + + instance = MagicMock() + instance.__attributes__ = {"record_type": "article", "record_id": 3} + + assert rel.apply_query(MagicMock(), instance) == "MODEL" + model.where.assert_called_once_with("id", 3) + + +def test_morph_to_many_get_related_single(): + model = MagicMock() + model.find.return_value = "FOUND" + + rel = MorphToMany(lambda self: None) + rel.morph_map = lambda: {"article": model} + + relation = MagicMock() + relation.record_type = "article" + relation.record_id = 8 + + assert rel.get_related(None, relation) == "FOUND" + model.find.assert_called_once_with([8]) + + +def test_morph_to_many_get_related_collection(): + morphed = MagicMock() + morphed.get_primary_key.return_value = "id" + morphed.get_table_name.return_value = "articles" + morphed.where_in.return_value.get.return_value = [Article(1)] + + rel = MorphToMany(lambda self: None) + rel.morph_map = lambda: {"article": morphed} + + relation = Collection( + [ + {"record_type": "article", "record_id": 1}, + {"record_type": "article", "record_id": 2}, + ] + ) + + result = rel.get_related(None, relation) + + assert isinstance(result, Collection) + morphed.where_in.assert_called_once() + + +def test_morph_to_many_register_related(): + morphed = MagicMock() + morphed.get_primary_key.return_value = "id" + + rel = MorphToMany(lambda self: None) + rel.morph_map = lambda: {"article": morphed} + + collection = MagicMock() + collection.where.return_value = "scoped" + + model = MagicMock() + model.record_type = "article" + model.record_id = 4 + + rel.register_related("record", model, collection) + + collection.where.assert_called_once_with("id", 4) + model.add_relation.assert_called_once_with({"record": "scoped"}) + + +# --------------------------------------------------------------------------- # +# MorphTo +# --------------------------------------------------------------------------- # +def test_morph_to_set_keys_defaults(): + rel = MorphTo("Like", morph_key=None, morph_id=None) + rel.set_keys() + + assert rel.morph_key == "record_type" + assert rel.morph_id == "record_id" + + +def test_morph_to_set_name_records_attribute(): + rel = MorphTo("Like") + rel.__set_name__(object, "record") + + assert rel.attribute == "record" + + +def test_morph_to_apply_query(): + model = MagicMock() + model.__primary_key__ = "id" + model.where.return_value.first.return_value = "MODEL" + + rel = MorphTo("Like") + rel.morph_map = lambda: {"article": model} + + instance = MagicMock() + instance.__attributes__ = {"record_type": "article", "record_id": 6} + + assert rel.apply_query(MagicMock(), instance) == "MODEL" + model.where.assert_called_once_with("id", 6) + + +def test_morph_to_register_related(): + morphed = MagicMock() + morphed.__primary_key__ = "id" + + rel = MorphTo("Like") + rel.morph_map = lambda: {"article": morphed} + + collection = MagicMock() + collection.where.return_value.first.return_value = "related" + + model = MagicMock() + model.record_type = "article" + model.record_id = 5 + + rel.register_related("record", model, collection) + + model.add_relation.assert_called_once_with({"record": "related"}) + + +def test_morph_to_map_related_returns_input(): + rel = MorphTo("Like") + payload = object() + + assert rel.map_related(payload) is payload + + +def test_morph_to_morph_map_reads_registry(monkeypatch): + monkeypatch.setattr(registry.Registry, "get_morph_map", classmethod(lambda cls: {"article": Article})) + rel = MorphTo("Like") + + assert rel.morph_map() == {"article": Article} + + +def test_morph_to_getattr_delegates_to_related_builder(monkeypatch): + resolved_instance = MagicMock() + resolved_instance._related_builder.some_attr = "delegated" + resolved_model = MagicMock(return_value=resolved_instance) + monkeypatch.setattr(registry.Registry, "resolve", classmethod(lambda cls, name: resolved_model)) + + rel = MorphTo("Like") + + assert rel.some_attr == "delegated" + + +async def test_morph_to_get_related_collection(monkeypatch): + from unittest.mock import AsyncMock + + morphed = MagicMock() + morphed.__table__ = "articles" + morphed.__primary_key__ = "id" + morphed.where_in.return_value.get = AsyncMock(return_value=Collection([Article(1)])) + + rel = MorphTo("Like") + rel.morph_map = lambda: {"article": morphed} + + relation = Collection( + [ + {"record_type": "article", "record_id": 1}, + {"record_type": "article", "record_id": 2}, + ] + ) + + result = await rel.get_related(None, relation) + + assert isinstance(result, Collection) + morphed.where_in.assert_called_once()