From 9f8bb9507d6bd02c695c38f6520ec8d8336cdd2a Mon Sep 17 00:00:00 2001 From: Bedram Tamang Date: Thu, 9 Jul 2026 11:38:09 -0700 Subject: [PATCH 1/4] Add unit tests for ORM relationship classes Cover BelongsToMany, MorphOne, MorphMany, MorphToMany, MorphTo, and the BaseRelationship base class with query-builder/DB mocks. Raises relationship module coverage from 44% to 76% (BelongsToMany 16% to 82%, BaseRelationship to 100%). --- .../masoniteorm/relationships/__init__.py | 0 .../masoniteorm/relationships/conftest.py | 34 ++ .../relationships/test_base_relationship.py | 127 ++++++ .../relationships/test_belongs_to_many.py | 355 ++++++++++++++++ .../relationships/test_morph_relations.py | 395 ++++++++++++++++++ 5 files changed, 911 insertions(+) create mode 100644 fastapi_startkit/tests/masoniteorm/relationships/__init__.py create mode 100644 fastapi_startkit/tests/masoniteorm/relationships/conftest.py create mode 100644 fastapi_startkit/tests/masoniteorm/relationships/test_base_relationship.py create mode 100644 fastapi_startkit/tests/masoniteorm/relationships/test_belongs_to_many.py create mode 100644 fastapi_startkit/tests/masoniteorm/relationships/test_morph_relations.py 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..63fdba60 --- /dev/null +++ b/fastapi_startkit/tests/masoniteorm/relationships/conftest.py @@ -0,0 +1,34 @@ +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..db6566c0 --- /dev/null +++ b/fastapi_startkit/tests/masoniteorm/relationships/test_base_relationship.py @@ -0,0 +1,127 @@ +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..d185eb45 --- /dev/null +++ b/fastapi_startkit/tests/masoniteorm/relationships/test_belongs_to_many.py @@ -0,0 +1,355 @@ +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") +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") +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") +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() + + +@patch("fastapi_startkit.masoniteorm.relationships.BelongsToMany.Pivot") +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") +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"): + 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"): + 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"): + 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..0738095b --- /dev/null +++ b/fastapi_startkit/tests/masoniteorm/relationships/test_morph_relations.py @@ -0,0 +1,395 @@ +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() + + assert rel.get_related(None, Article(3)) == "GET_RESULT" + + +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()) + + rel.get_related(None, Article(3), callback=callback) + + callback.assert_called_once() + + +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)]) + assert rel.get_related(None, relation) == "GET_RESULT" + + +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 + + assert rel.apply_query(owner_builder, instance) == "FIRST_RESULT" + + +def test_morph_one_get_related_single(): + rel = MorphOne(lambda self: None) + rel.morph_map = lambda: {"article": Article} + rel.polymorphic_builder = morph_builder() + + assert rel.get_related(None, Article(4)) == "FIRST_RESULT" + + +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)]) + assert rel.get_related(None, relation) == "GET_RESULT" + + +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() From 0c4b5dee76494b8c4c3e6037106dbab14f61226c Mon Sep 17 00:00:00 2001 From: Bedram Tamang Date: Thu, 9 Jul 2026 11:42:04 -0700 Subject: [PATCH 2/4] style: fix Ruff lint/format on relationship tests Remove unused pytest import (F401) in test_belongs_to_many.py and apply ruff format to satisfy CI. Co-Authored-By: Claude Opus 4.8 --- .../relationships/test_belongs_to_many.py | 13 +++---------- .../relationships/test_morph_relations.py | 12 +++--------- 2 files changed, 6 insertions(+), 19 deletions(-) diff --git a/fastapi_startkit/tests/masoniteorm/relationships/test_belongs_to_many.py b/fastapi_startkit/tests/masoniteorm/relationships/test_belongs_to_many.py index d185eb45..c576ee74 100644 --- a/fastapi_startkit/tests/masoniteorm/relationships/test_belongs_to_many.py +++ b/fastapi_startkit/tests/masoniteorm/relationships/test_belongs_to_many.py @@ -1,6 +1,5 @@ from unittest.mock import MagicMock, patch -import pytest from fastapi_startkit.masoniteorm.collection import Collection from fastapi_startkit.masoniteorm.models import registry @@ -36,9 +35,7 @@ def make_relationship(table="store_product", **kwargs): def test_init_resolves_string_name(monkeypatch): - monkeypatch.setattr( - registry.Registry, "resolve", classmethod(lambda cls, name: "resolved") - ) + monkeypatch.setattr(registry.Registry, "resolve", classmethod(lambda cls, name: "resolved")) rel = BelongsToMany("Product") assert rel.fn("x") == "resolved" @@ -201,9 +198,7 @@ def test_attach_includes_timestamps(pivot): @patch("fastapi_startkit.masoniteorm.relationships.BelongsToMany.Pivot") 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 - ) + chain = pivot.on.return_value.table.return_value.without_global_scopes.return_value.where.return_value current = MagicMock() current.id = 1 @@ -235,9 +230,7 @@ def test_attach_related_creates_pivot_record(pivot): @patch("fastapi_startkit.masoniteorm.relationships.BelongsToMany.Pivot") 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 - ) + chain = pivot.on.return_value.table.return_value.without_global_scopes.return_value.where.return_value current = MagicMock() current.id = 2 diff --git a/fastapi_startkit/tests/masoniteorm/relationships/test_morph_relations.py b/fastapi_startkit/tests/masoniteorm/relationships/test_morph_relations.py index 0738095b..bd195b23 100644 --- a/fastapi_startkit/tests/masoniteorm/relationships/test_morph_relations.py +++ b/fastapi_startkit/tests/masoniteorm/relationships/test_morph_relations.py @@ -126,9 +126,7 @@ def test_morph_many_register_related(): def test_morph_many_morph_map_reads_registry(monkeypatch): - monkeypatch.setattr( - registry.Registry, "get_morph_map", classmethod(lambda cls: {"article": Article}) - ) + monkeypatch.setattr(registry.Registry, "get_morph_map", classmethod(lambda cls: {"article": Article})) rel = MorphMany(lambda self: None) assert rel.morph_map() == {"article": Article} @@ -350,9 +348,7 @@ def test_morph_to_map_related_returns_input(): def test_morph_to_morph_map_reads_registry(monkeypatch): - monkeypatch.setattr( - registry.Registry, "get_morph_map", classmethod(lambda cls: {"article": Article}) - ) + monkeypatch.setattr(registry.Registry, "get_morph_map", classmethod(lambda cls: {"article": Article})) rel = MorphTo("Like") assert rel.morph_map() == {"article": Article} @@ -362,9 +358,7 @@ 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) - ) + monkeypatch.setattr(registry.Registry, "resolve", classmethod(lambda cls, name: resolved_model)) rel = MorphTo("Like") From 964e65f7fca5974e4eac3a5dbb07d462d820dca4 Mon Sep 17 00:00:00 2001 From: Bedram Tamang Date: Thu, 9 Jul 2026 13:22:35 -0700 Subject: [PATCH 3/4] docs(tests): explain why relationship tests mock the query builder The masoniteorm relationship classes are not wired to this fork's async QueryBuilder, so the relationship unit tests mock the builder instead of running against real sqlite. Document the specifics inline (missing QueryBuilder.table()/without_global_scopes()/add_select(), the BaseRelationship.get_builder() no-arg self.fn() TypeError, the attach/detach Pivot chain, and the MorphOne/MorphToMany undefined load_config) so the intent is clear and the framework gaps are traceable. No behavioral or framework changes. --- .../masoniteorm/relationships/conftest.py | 20 +++++++++++++++ .../relationships/test_base_relationship.py | 10 ++++++++ .../relationships/test_belongs_to_many.py | 25 +++++++++++++++++++ .../relationships/test_morph_relations.py | 12 +++++++++ 4 files changed, 67 insertions(+) diff --git a/fastapi_startkit/tests/masoniteorm/relationships/conftest.py b/fastapi_startkit/tests/masoniteorm/relationships/conftest.py index 63fdba60..ada30fe7 100644 --- a/fastapi_startkit/tests/masoniteorm/relationships/conftest.py +++ b/fastapi_startkit/tests/masoniteorm/relationships/conftest.py @@ -1,3 +1,23 @@ +"""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 diff --git a/fastapi_startkit/tests/masoniteorm/relationships/test_base_relationship.py b/fastapi_startkit/tests/masoniteorm/relationships/test_base_relationship.py index db6566c0..0f51507f 100644 --- a/fastapi_startkit/tests/masoniteorm/relationships/test_base_relationship.py +++ b/fastapi_startkit/tests/masoniteorm/relationships/test_base_relationship.py @@ -1,3 +1,13 @@ +"""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 diff --git a/fastapi_startkit/tests/masoniteorm/relationships/test_belongs_to_many.py b/fastapi_startkit/tests/masoniteorm/relationships/test_belongs_to_many.py index c576ee74..e075dc45 100644 --- a/fastapi_startkit/tests/masoniteorm/relationships/test_belongs_to_many.py +++ b/fastapi_startkit/tests/masoniteorm/relationships/test_belongs_to_many.py @@ -1,3 +1,28 @@ +"""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 diff --git a/fastapi_startkit/tests/masoniteorm/relationships/test_morph_relations.py b/fastapi_startkit/tests/masoniteorm/relationships/test_morph_relations.py index bd195b23..00277d58 100644 --- a/fastapi_startkit/tests/masoniteorm/relationships/test_morph_relations.py +++ b/fastapi_startkit/tests/masoniteorm/relationships/test_morph_relations.py @@ -1,3 +1,15 @@ +"""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 7de5e74de9c4a275af63ae96a5f857eb81a32c99 Mon Sep 17 00:00:00 2001 From: Bedram Tamang Date: Thu, 9 Jul 2026 17:53:01 -0700 Subject: [PATCH 4/4] test(orm): assert real query args in morph tests + autospec Pivot mocks Rewrite six tautological morph relationship tests that only asserted the mock's canned return value (GET_RESULT/FIRST_RESULT) so they verify the real arguments the source constructs: - morph_many/morph_one get_related (single): assert where("record_type", ...) and where("record_id", pk); MorphOne additionally asserts .first() over .get() - get_related (collection): assert the ".record_type" filter and that where_in receives the plucked/unique ids - get_related with callback: assert the builder passed to the callback and the where() args, not just callback.called - morph_one apply_query: keep the .first()-not-.get() check, add real arg asserts Add autospec=True to the Pivot patches in the BelongsToMany tests so signature drift is caught instead of masked by bare MagicMock. attach_related() calls a non-existent Pivot.table() classmethod; the autospec surfaces this as an AttributeError, marked xfail(strict) pending the framework fix (backlog #738). --- .../relationships/test_belongs_to_many.py | 26 ++++++--- .../relationships/test_morph_relations.py | 56 ++++++++++++++++--- 2 files changed, 67 insertions(+), 15 deletions(-) diff --git a/fastapi_startkit/tests/masoniteorm/relationships/test_belongs_to_many.py b/fastapi_startkit/tests/masoniteorm/relationships/test_belongs_to_many.py index e075dc45..ed29d515 100644 --- a/fastapi_startkit/tests/masoniteorm/relationships/test_belongs_to_many.py +++ b/fastapi_startkit/tests/masoniteorm/relationships/test_belongs_to_many.py @@ -25,6 +25,7 @@ from unittest.mock import MagicMock, patch +import pytest from fastapi_startkit.masoniteorm.collection import Collection from fastapi_startkit.masoniteorm.models import registry @@ -187,7 +188,7 @@ def test_get_with_count_query_selects_star_when_no_columns(): builder.select.assert_called_once_with("*") -@patch("fastapi_startkit.masoniteorm.relationships.BelongsToMany.Pivot") +@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 @@ -203,7 +204,7 @@ def test_attach_creates_pivot_record(pivot): chain.create.assert_called_once_with({"store_id": 1, "product_id": 9}) -@patch("fastapi_startkit.masoniteorm.relationships.BelongsToMany.Pivot") +@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 @@ -220,7 +221,7 @@ def test_attach_includes_timestamps(pivot): assert "created_at" in data and "updated_at" in data -@patch("fastapi_startkit.masoniteorm.relationships.BelongsToMany.Pivot") +@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 @@ -236,7 +237,16 @@ def test_detach_deletes_pivot_record(pivot): chain.delete.assert_called_once() -@patch("fastapi_startkit.masoniteorm.relationships.BelongsToMany.Pivot") +# ``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 @@ -252,7 +262,7 @@ def test_attach_related_creates_pivot_record(pivot): chain.create.assert_called_once_with({"store_id": 2, "product_id": 7}) -@patch("fastapi_startkit.masoniteorm.relationships.BelongsToMany.Pivot") +@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 @@ -312,7 +322,7 @@ async def test_get_related_hydrates_pivot(monkeypatch): 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"): + with patch("fastapi_startkit.masoniteorm.relationships.BelongsToMany.Pivot", autospec=True): owner_query = make_builder(table_name="stores") class Relation: @@ -350,7 +360,7 @@ async def test_apply_query_hydrates_with_timestamps_and_fields(): owner.get_table_name.return_value = "stores" owner.id = 5 - with patch("fastapi_startkit.masoniteorm.relationships.BelongsToMany.Pivot"): + with patch("fastapi_startkit.masoniteorm.relationships.BelongsToMany.Pivot", autospec=True): out = await rel.apply_query(query, owner) assert out is fetched @@ -367,7 +377,7 @@ async def test_apply_query_infers_pivot_table_when_missing(): owner = MagicMock() owner.get_table_name.return_value = "stores" - with patch("fastapi_startkit.masoniteorm.relationships.BelongsToMany.Pivot"): + 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 index 00277d58..c34e9b9c 100644 --- a/fastapi_startkit/tests/masoniteorm/relationships/test_morph_relations.py +++ b/fastapi_startkit/tests/masoniteorm/relationships/test_morph_relations.py @@ -98,7 +98,13 @@ def test_morph_many_get_related_single(): rel.morph_map = lambda: {"article": Article} rel.polymorphic_builder = morph_builder() - assert rel.get_related(None, Article(3)) == "GET_RESULT" + 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(): @@ -107,9 +113,14 @@ def test_morph_many_get_related_single_with_callback(): rel.polymorphic_builder = morph_builder() callback = MagicMock(return_value=morph_builder()) - rel.get_related(None, Article(3), callback=callback) + result = rel.get_related(None, Article(3), callback=callback) - callback.assert_called_once() + 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(): @@ -118,7 +129,15 @@ def test_morph_many_get_related_collection(): rel.polymorphic_builder = morph_builder() relation = Collection([Article(1), Article(2)]) - assert rel.get_related(None, relation) == "GET_RESULT" + 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(): @@ -172,7 +191,15 @@ def test_morph_one_apply_query_returns_first(): instance = MagicMock() instance.get_primary_key_value.return_value = 2 - assert rel.apply_query(owner_builder, instance) == "FIRST_RESULT" + 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(): @@ -180,7 +207,14 @@ def test_morph_one_get_related_single(): rel.morph_map = lambda: {"article": Article} rel.polymorphic_builder = morph_builder() - assert rel.get_related(None, Article(4)) == "FIRST_RESULT" + 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(): @@ -189,7 +223,15 @@ def test_morph_one_get_related_collection(): rel.polymorphic_builder = morph_builder() relation = Collection([Article(1), Article(2)]) - assert rel.get_related(None, relation) == "GET_RESULT" + 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():