From 3f720389db6a27db824d2739f3c42d76e5dabca9 Mon Sep 17 00:00:00 2001 From: Bedram Tamang Date: Thu, 23 Jul 2026 14:28:07 -0700 Subject: [PATCH] test: raise fastapi_startkit coverage to >=80% and enforce threshold Add real unit tests for previously under-covered pure-logic modules and raise the coverage gate from 68 to 80 now that actual coverage supports it. Modules gaining coverage: - masoniteorm/expressions/expressions.py (67% -> ~100%): alias splitting, order-by direction inference, join ON-clause construction, operator validation - support/string.py (82% -> 100%): Str/Stringable slug/trim/camel/snake helpers - utils/structures.py (23% -> ~96%): dotty data get/set, wildcard, module loader - storage/drivers/local.py (77% -> ~96%): put_file, store, stream, path resolution - masoniteorm/schema/Column.py (72% -> 100%): fluent column builder methods Total coverage 79.87% -> 80.93%; fail_under 68 -> 80. --- fastapi_startkit/pyproject.toml | 2 +- .../masoniteorm/query/test_expressions.py | 229 ++++++++++++++++++ .../tests/masoniteorm/schema/test_column.py | 103 ++++++++ .../tests/storage/test_local_driver.py | 90 +++++++ fastapi_startkit/tests/support/__init__.py | 0 fastapi_startkit/tests/support/test_string.py | 63 +++++ .../tests/utils/test_structures.py | 83 +++++++ 7 files changed, 569 insertions(+), 1 deletion(-) create mode 100644 fastapi_startkit/tests/masoniteorm/query/test_expressions.py create mode 100644 fastapi_startkit/tests/masoniteorm/schema/test_column.py create mode 100644 fastapi_startkit/tests/support/__init__.py create mode 100644 fastapi_startkit/tests/support/test_string.py create mode 100644 fastapi_startkit/tests/utils/test_structures.py diff --git a/fastapi_startkit/pyproject.toml b/fastapi_startkit/pyproject.toml index 3bc983e2..d0235b82 100644 --- a/fastapi_startkit/pyproject.toml +++ b/fastapi_startkit/pyproject.toml @@ -149,7 +149,7 @@ omit = [ [tool.coverage.report] show_missing = true skip_covered = false -fail_under = 68 +fail_under = 80 exclude_lines = [ "pragma: no cover", "if TYPE_CHECKING:", diff --git a/fastapi_startkit/tests/masoniteorm/query/test_expressions.py b/fastapi_startkit/tests/masoniteorm/query/test_expressions.py new file mode 100644 index 00000000..60b8ab41 --- /dev/null +++ b/fastapi_startkit/tests/masoniteorm/query/test_expressions.py @@ -0,0 +1,229 @@ +"""Unit tests for ORM query expression helper classes (task #1214). + +These classes carry the parsing/normalisation logic the grammars rely on +(alias splitting, direction inference, ON-clause construction), so the tests +assert on that behaviour rather than merely instantiating the objects. +""" + +import warnings + +import pytest + +from fastapi_startkit.masoniteorm.expressions.expressions import ( + AggregateExpression, + BetweenExpression, + GroupByExpression, + HavingExpression, + JoinClause, + OnClause, + OnValueClause, + OrderByExpression, + QueryExpression, + Raw, + SelectExpression, + SubGroupExpression, + SubSelectExpression, + UpdateQueryExpression, +) + + +class TestQueryExpression: + def test_stores_all_attributes(self): + expr = QueryExpression("age", ">", 18, value_type="value", keyword="where") + assert expr.column == "age" + assert expr.equality == ">" + assert expr.value == 18 + assert expr.value_type == "value" + assert expr.keyword == "where" + assert expr.raw is False + assert expr.bindings == () + + +class TestHavingExpression: + def test_infers_equality_when_only_value_given(self): + expr = HavingExpression("total", 100) + assert expr.equality == "=" + assert expr.value == 100 + assert expr.value_type == "having" + + def test_keeps_explicit_equality_and_value(self): + expr = HavingExpression("total", ">=", 100) + assert expr.equality == ">=" + assert expr.value == 100 + + +class TestBetweenExpression: + def test_defaults(self): + expr = BetweenExpression("age", 18, 30) + assert expr.low == 18 + assert expr.high == 30 + assert expr.equality == "BETWEEN" + assert expr.value_type == "BETWEEN" + assert expr.value is None + assert expr.raw is False + + +class TestSelectExpression: + def test_splits_column_and_alias(self): + expr = SelectExpression("name as full_name") + assert expr.column == "name" + assert expr.alias == "full_name" + + def test_strips_surrounding_whitespace(self): + expr = SelectExpression(" email ") + assert expr.column == "email" + assert expr.alias is None + + def test_raw_column_is_not_split(self): + expr = SelectExpression("count(*) as total", raw=True) + assert expr.column == "count(*) as total" + assert expr.alias is None + + +class TestOrderByExpression: + def test_defaults_to_ascending(self): + expr = OrderByExpression("name") + assert expr.column == "name" + assert expr.direction == "ASC" + + def test_infers_descending_from_suffix(self): + expr = OrderByExpression("created_at desc") + assert expr.column == "created_at" + assert expr.direction == "DESC" + + def test_infers_ascending_from_suffix(self): + expr = OrderByExpression("name asc") + assert expr.column == "name" + assert expr.direction == "ASC" + + def test_raw_disables_suffix_parsing(self): + expr = OrderByExpression("name desc", raw=True) + assert expr.column == "name desc" + assert expr.direction == "ASC" + + +class TestGroupByExpression: + def test_strips_column(self): + expr = GroupByExpression(" category ") + assert expr.column == "category" + assert expr.raw is False + + +class TestAggregateExpression: + def test_plain_column(self): + expr = AggregateExpression(aggregate="SUM", column="amount") + assert expr.aggregate == "SUM" + assert expr.column == "amount" + assert expr.alias is False + + def test_splits_alias(self): + expr = AggregateExpression(aggregate="SUM", column="amount as total") + assert expr.column == "amount" + assert expr.alias == "total" + + +class TestRaw: + def test_stores_expression(self): + assert Raw("NOW()").expression == "NOW()" + + +class TestUpdateQueryExpression: + def test_defaults(self): + expr = UpdateQueryExpression("name", "bob") + assert expr.column == "name" + assert expr.value == "bob" + assert expr.update_type == "keyvalue" + + +class TestSubExpressions: + def test_sub_select_holds_builder(self): + sentinel = object() + assert SubSelectExpression(sentinel).builder is sentinel + + def test_sub_group_default_alias(self): + sentinel = object() + expr = SubGroupExpression(sentinel) + assert expr.builder is sentinel + assert expr.alias == "group" + + +class TestJoinClause: + def test_parses_table_alias(self): + clause = JoinClause("users as u") + assert clause.table == "users" + assert clause.alias == "u" + assert clause.clause == "join" + + def test_no_alias(self): + clause = JoinClause("users", clause="left") + assert clause.table == "users" + assert clause.alias is None + assert clause.clause == "left" + + def test_on_builds_and_clause(self): + clause = JoinClause("users").on("users.id", "=", "posts.user_id") + [on] = clause.get_on_clauses() + assert isinstance(on, OnClause) + assert on.column1 == "users.id" + assert on.column2 == "posts.user_id" + assert on.operator == "and" + + def test_or_on_builds_or_clause(self): + clause = JoinClause("users").or_on("a", "=", "b") + assert clause.get_on_clauses()[0].operator == "or" + + def test_chaining_returns_self(self): + clause = JoinClause("users") + assert clause.on("a", "=", "b") is clause + + def test_on_value_with_operator_and_value(self): + clause = JoinClause("users").on_value("age", ">", 18) + on = clause.get_on_clauses()[0] + assert isinstance(on, OnValueClause) + assert on.equality == ">" + assert on.value == 18 + assert on.operator == "and" + + def test_on_value_with_single_value_defaults_operator(self): + clause = JoinClause("users").on_value("active", 1) + on = clause.get_on_clauses()[0] + assert on.equality == "=" + assert on.value == 1 + + def test_or_on_value_sets_or_operator(self): + clause = JoinClause("users").or_on_value("age", ">", 18) + assert clause.get_on_clauses()[0].operator == "or" + + def test_on_null(self): + clause = JoinClause("users").on_null("deleted_at") + on = clause.get_on_clauses()[0] + assert on.value_type == "NULL" + assert on.value is None + + def test_on_not_null(self): + clause = JoinClause("users").on_not_null("verified_at") + on = clause.get_on_clauses()[0] + assert on.value_type == "NOT NULL" + assert on.value is True + + def test_or_on_null(self): + clause = JoinClause("users").or_on_null("deleted_at") + assert clause.get_on_clauses()[0].operator == "or" + + def test_or_on_not_null(self): + clause = JoinClause("users").or_on_not_null("verified_at") + on = clause.get_on_clauses()[0] + assert on.operator == "or" + assert on.value_type == "NOT NULL" + + def test_invalid_operator_raises(self): + with pytest.raises(ValueError): + JoinClause("users").on_value("age", "bogus", 18) + + def test_where_is_deprecated_alias_of_on_value(self): + clause = JoinClause("users") + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + result = clause.where("age", ">", 18) + assert result is clause + assert clause.get_on_clauses()[0].equality == ">" diff --git a/fastapi_startkit/tests/masoniteorm/schema/test_column.py b/fastapi_startkit/tests/masoniteorm/schema/test_column.py new file mode 100644 index 00000000..fac2c09c --- /dev/null +++ b/fastapi_startkit/tests/masoniteorm/schema/test_column.py @@ -0,0 +1,103 @@ +"""Unit tests for the schema Column builder (task #1214).""" + +from fastapi_startkit.masoniteorm.schema.Column import Column + + +def make_column(**kwargs): + defaults = {"name": "email", "column_type": "string"} + defaults.update(kwargs) + return Column(**defaults) + + +class TestColumnDefaults: + def test_initial_state(self): + col = make_column(length=255) + assert col.name == "email" + assert col.column_type == "string" + assert col.length == 255 + assert col.values == [] + assert col.is_null is False + assert col.primary is False + assert col.comment is None + + def test_values_defaults_to_empty_list(self): + assert make_column(values=None).values == [] + assert make_column(values=["a", "b"]).values == ["a", "b"] + + +class TestNullability: + def test_nullable_sets_flag_and_returns_self(self): + col = make_column() + assert col.nullable() is col + assert col.is_null is True + + def test_not_nullable_clears_flag(self): + col = make_column(nullable=True) + assert col.not_nullable() is col + assert col.is_null is False + + +class TestSignedness: + def test_signed(self): + col = make_column() + assert col.signed() is col + assert col._signed == "signed" + + def test_unsigned(self): + col = make_column() + assert col.unsigned() is col + assert col._signed == "unsigned" + + +class TestPrimaryAndComment: + def test_set_as_primary(self): + col = make_column() + col.set_as_primary() + assert col.primary is True + + def test_add_comment_returns_self(self): + col = make_column() + assert col.add_comment("the user email") is col + assert col.comment == "the user email" + + +class TestRenameAndPositioning: + def test_rename_records_old_column(self): + col = make_column() + assert col.rename("old_email") is col + assert col.old_column == "old_email" + + def test_after_sets_and_get_after_column_reads(self): + col = make_column() + assert col.after("created_at") is col + assert col.get_after_column() == "created_at" + + def test_get_after_column_defaults_to_none(self): + assert make_column().get_after_column() is None + + +class TestChangeAndCurrent: + def test_change_marks_modify_action(self): + col = make_column() + assert col.change() is col + assert col._action == "modify" + + def test_use_current_sets_default_current(self): + col = make_column() + assert col.use_current() is col + assert col.default == "current" + + +class TestDefaultValue: + def test_default_value_is_stored_as_attribute_from_constructor(self): + col = make_column(default="anon", default_is_raw=True) + assert col.default == "anon" + assert col.default_is_raw is True + + def test_default_method_is_reachable_on_the_class(self): + # The constructor assigns ``self.default`` as an attribute, shadowing the + # method on instances; the method is still invocable via the class. + col = make_column() + assert Column.default(col, "seed", raw=True) is col + assert col.default == "seed" + assert col.default_is_raw is True diff --git a/fastapi_startkit/tests/storage/test_local_driver.py b/fastapi_startkit/tests/storage/test_local_driver.py index 84a2aa06..de2b9bcb 100644 --- a/fastapi_startkit/tests/storage/test_local_driver.py +++ b/fastapi_startkit/tests/storage/test_local_driver.py @@ -7,6 +7,7 @@ from fastapi_startkit.storage.drivers.fake import FakeDriver from fastapi_startkit.storage.drivers.local import LocalDriver +from fastapi_startkit.storage.file import File from fastapi_startkit.storage.filestream import FileStream @@ -103,6 +104,95 @@ def test_get_files_lists_files_in_directory(self, driver, tmp_path): names = [f.name() for f in files] assert set(names) == {"a.txt", "b.txt"} + def test_get_files_skips_subdirectories(self, driver, tmp_path): + storage = tmp_path / "storage" + storage.mkdir(parents=True, exist_ok=True) + (storage / "file.txt").write_text("x") + (storage / "nested").mkdir() + files = driver.get_files("") + assert [f.name() for f in files] == ["file.txt"] + + +class TestLocalDriverPathResolution: + def test_get_path_joins_relative_root_to_base_path(self, tmp_path): + app = MagicMock() + app.base_path = str(tmp_path) + driver = LocalDriver(app) + driver.set_options({"root": "storage"}) + resolved = driver.get_path("file.txt") + assert resolved == os.path.join(str(tmp_path), "storage", "file.txt") + + def test_get_path_uses_path_option_as_fallback(self, tmp_path): + app = MagicMock() + app.base_path = str(tmp_path) + driver = LocalDriver(app) + driver.set_options({"path": str(tmp_path / "assets")}) + assert driver.get_path("f.txt").startswith(str(tmp_path / "assets")) + + def test_get_name_appends_extension_to_alias(self, tmp_path): + app = MagicMock() + app.base_path = str(tmp_path) + driver = LocalDriver(app) + assert driver.get_name("photo.png", "avatar") == "avatar.png" + + def test_make_directory_is_noop(self, tmp_path): + app = MagicMock() + app.base_path = str(tmp_path) + driver = LocalDriver(app) + assert driver.make_directory("anything") is None + + +class _Upload: + """Minimal stand-in for an uploaded file: a string ``name`` and ``get_content``.""" + + def __init__(self, name, content): + self.name = name + self._content = content + + def get_content(self): + return self._content + + +class TestLocalDriverPutFileAndStore: + @pytest.fixture + def driver(self, tmp_path): + app = MagicMock() + app.base_path = str(tmp_path) + d = LocalDriver(app) + d.set_options({"root": str(tmp_path / "storage")}) + return d + + def test_put_file_writes_named_content(self, driver, tmp_path): + content = _Upload("source.txt", b"hello world") + stored = driver.put_file("uploads", content, name="renamed") + assert stored == os.path.join("uploads", "renamed.txt") + assert (tmp_path / "storage" / "uploads" / "renamed.txt").read_bytes() == b"hello world" + + def test_put_file_generates_name_when_missing(self, driver, tmp_path): + content = _Upload("source.txt", b"data") + stored = driver.put_file("uploads", content) + assert stored.endswith(".txt") + assert (tmp_path / "storage" / stored).exists() + + def test_store_writes_file_using_hash_path(self, driver, tmp_path): + f = File(b"binary-content", "report.pdf") + full_path = driver.store(f) + assert full_path.endswith(".pdf") + assert os.path.exists(full_path) + with open(full_path, "rb") as fh: + assert fh.read() == b"binary-content" + + def test_store_with_explicit_name(self, driver, tmp_path): + f = File(b"x", "report.pdf") + full_path = driver.store(f, name="custom") + assert os.path.basename(full_path) == "custom.pdf" + + def test_stream_returns_filestream(self, driver, tmp_path): + (tmp_path / "storage").mkdir(parents=True, exist_ok=True) + (tmp_path / "storage" / "streamed.txt").write_text("streamed") + stream = driver.stream("streamed.txt") + assert isinstance(stream, FileStream) + # --------------------------------------------------------------------------- # FakeDriver — in-memory / temp directory behaviour diff --git a/fastapi_startkit/tests/support/__init__.py b/fastapi_startkit/tests/support/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/fastapi_startkit/tests/support/test_string.py b/fastapi_startkit/tests/support/test_string.py new file mode 100644 index 00000000..96f9af31 --- /dev/null +++ b/fastapi_startkit/tests/support/test_string.py @@ -0,0 +1,63 @@ +"""Unit tests for the Str / Stringable string helpers (task #1214).""" + +from fastapi_startkit.support.string import Str, Stringable + + +class TestStrSlugify: + def test_removes_non_alphanumeric_and_lowercases(self): + assert Str.slugify("Hello, World!") == "helloworld" + + def test_keeps_digits(self): + assert Str.slugify("Order #42 Ready") == "order42ready" + + +class TestStrTrim: + def test_removes_word_case_insensitive(self): + assert Str.trim("UserController", "controller") == "User" + + def test_strips_leftover_underscores(self): + assert Str.trim("make_model_command", "command") == "make_model" + + def test_no_match_returns_stripped_original(self): + assert Str.trim(" plain ", "zzz") == "plain" + + +class TestStrCamelCase: + def test_converts_snake_to_camel(self): + assert Str.camel_case("make_model_command") == "makeModelCommand" + + def test_handles_spaces_and_dashes(self): + assert Str.camel_case("some-mixed value") == "someMixedValue" + + def test_single_word_is_lowercased(self): + assert Str.camel_case("Word") == "word" + + +class TestStrSnakeCase: + def test_converts_camel_to_snake(self): + assert Str.snake_case("makeModelCommand") == "make_model_command" + + def test_handles_acronyms(self): + assert Str.snake_case("HTTPResponse") == "http_response" + + def test_converts_spaces_and_dashes(self): + assert Str.snake_case("some-mixed value") == "some_mixed_value" + + +class TestStringableFluent: + def test_of_returns_stringable(self): + assert isinstance(Str.of("hello"), Stringable) + + def test_str_returns_text(self): + assert str(Str.of("hello")) == "hello" + + def test_chaining_operations(self): + result = Str.of("UserController").trim("Controller").snake_case() + assert isinstance(result, Stringable) + assert str(result) == "user" + + def test_slugify_returns_stringable(self): + assert str(Str.of("Hi There!").slugify()) == "hithere" + + def test_camel_case_returns_stringable(self): + assert str(Str.of("make_model").camel_case()) == "makeModel" diff --git a/fastapi_startkit/tests/utils/test_structures.py b/fastapi_startkit/tests/utils/test_structures.py new file mode 100644 index 00000000..5b0dab92 --- /dev/null +++ b/fastapi_startkit/tests/utils/test_structures.py @@ -0,0 +1,83 @@ +"""Unit tests for the data-structure helpers in utils/structures.py (task #1214).""" + +import pytest +from dotty_dict import Dotty + +from fastapi_startkit.exceptions.exceptions import LoaderNotFound +from fastapi_startkit.utils.structures import data, data_get, data_set, load + + +class TestData: + def test_wraps_dict_in_dotty(self): + result = data({"a": {"b": 1}}) + assert isinstance(result, Dotty) + assert result["a.b"] == 1 + + def test_none_yields_empty_dotty(self): + result = data(None) + assert isinstance(result, Dotty) + assert result.to_dict() == {} + + +class TestDataGet: + def test_reads_nested_value(self): + assert data_get({"app": {"name": "startkit"}}, "app.name") == "startkit" + + def test_missing_key_returns_default(self): + assert data_get({"app": {}}, "app.missing", "fallback") == "fallback" + + def test_wildcard_collects_matches_across_a_list(self): + source = {"users": [{"name": "ann"}, {"name": "bob"}]} + assert data_get(source, "users.*.name") == ["ann", "bob"] + + +class TestDataSet: + def test_sets_nested_value_and_returns_same_dict(self): + target = {"app": {}} + result = data_set(target, "app.name", "startkit") + assert result is target + assert target["app"]["name"] == "startkit" + + def test_overwrites_by_default(self): + target = {"app": {"name": "old"}} + data_set(target, "app.name", "new") + assert target["app"]["name"] == "new" + + def test_no_overwrite_keeps_existing_value(self): + target = {"app": {"name": "old"}} + result = data_set(target, "app.name", "new", overwrite=False) + assert result is None + assert target["app"]["name"] == "old" + + def test_wildcard_key_raises(self): + with pytest.raises(ValueError): + data_set({}, "users.*.name", "x") + + +class TestLoad: + def _write_module(self, tmp_path): + module = tmp_path / "sample_module.py" + module.write_text("VALUE = 42\n\n\ndef greet():\n return 'hi'\n") + return str(module) + + def test_loads_whole_module(self, tmp_path): + path = self._write_module(tmp_path) + module = load(path) + assert module.VALUE == 42 + assert module.greet() == "hi" + + def test_loads_named_object(self, tmp_path): + path = self._write_module(tmp_path) + assert load(path, "VALUE") == 42 + + def test_missing_object_raises_attribute_error(self, tmp_path): + path = self._write_module(tmp_path) + with pytest.raises(AttributeError): + load(path, "MISSING", default="fallback") + + def test_bad_path_returns_none(self): + assert load("/no/such/module.py") is None + + def test_bad_path_raises_when_requested(self): + with pytest.raises(LoaderNotFound): + load("/no/such/module.py", raise_exception=True)