From 18171bfa1260843a74356288af9b80a4a06ab33e Mon Sep 17 00:00:00 2001 From: Bedram Tamang Date: Thu, 9 Jul 2026 11:26:05 -0700 Subject: [PATCH 1/7] Add test coverage for masoniteorm make and seed console commands Cover the previously untested Cleo commands in masoniteorm/commands: db:make:migration, db:make:model, seed (make seeder), observer, db:seed and db:migrate:refresh. Generator commands are exercised in an isolated temp working directory and assert on the emitted files and CLI output. db:seed is tested with a fake Seeder that records constructor args and awaited methods, mocking database side effects while verifying argument/option parsing and the handle path. --- .../commands/test_db_seed_command.py | 97 ++++++++++ .../commands/test_make_commands.py | 170 ++++++++++++++++++ .../commands/test_migrate_commands.py | 12 ++ 3 files changed, 279 insertions(+) create mode 100644 fastapi_startkit/tests/masoniteorm/commands/test_db_seed_command.py create mode 100644 fastapi_startkit/tests/masoniteorm/commands/test_make_commands.py diff --git a/fastapi_startkit/tests/masoniteorm/commands/test_db_seed_command.py b/fastapi_startkit/tests/masoniteorm/commands/test_db_seed_command.py new file mode 100644 index 00000000..ebc40860 --- /dev/null +++ b/fastapi_startkit/tests/masoniteorm/commands/test_db_seed_command.py @@ -0,0 +1,97 @@ +import unittest +from unittest import mock + +from cleo.testers.command_tester import CommandTester + +from fastapi_startkit.masoniteorm.commands.DBSeedCommand import DBSeedCommand + + +class FakeSeeder: + """Records constructor args and awaited methods, mocking DB side effects.""" + + instances = [] + + def __init__(self, seed_path="databases/seeds", connection=None): + self.seed_path = seed_path + self.connection = connection + self.calls = [] + FakeSeeder.instances.append(self) + + async def run_database_seed(self): + self.calls.append(("run_database_seed", None)) + + async def run_specific_seed(self, seed): + self.calls.append(("run_specific_seed", seed)) + + +class TestDBSeedCommand(unittest.TestCase): + def setUp(self): + FakeSeeder.instances = [] + patcher = mock.patch( + "fastapi_startkit.masoniteorm.seeds.Seeder", + FakeSeeder, + ) + patcher.start() + self.addCleanup(patcher.stop) + + def _run(self, args=""): + tester = CommandTester(DBSeedCommand()) + tester.execute(args) + return tester.io.fetch_output() + + def test_runs_database_seeder_by_default(self): + output = self._run("") + + self.assertIn("Database Seeder seeded!", output) + seeder = FakeSeeder.instances[-1] + self.assertEqual(seeder.calls, [("run_database_seed", None)]) + self.assertEqual(seeder.seed_path, "databases/seeds") + self.assertEqual(seeder.connection, "default") + + def test_seeds_specific_table_from_argument(self): + output = self._run("posts") + + self.assertIn("PostsTableSeeder seeded!", output) + seeder = FakeSeeder.instances[-1] + self.assertEqual( + seeder.calls, + [("run_specific_seed", "posts_table_seeder.PostsTableSeeder")], + ) + + def test_class_option_resolves_plain_class_name(self): + output = self._run("--class PostSeeder") + + self.assertIn("PostSeeder seeded!", output) + seeder = FakeSeeder.instances[-1] + self.assertEqual( + seeder.calls, + [("run_specific_seed", "post_seeder.PostSeeder")], + ) + + def test_class_option_resolves_table_seeder_suffix(self): + output = self._run("--class PostTableSeeder") + + self.assertIn("PostTableSeeder seeded!", output) + seeder = FakeSeeder.instances[-1] + self.assertEqual( + seeder.calls, + [("run_specific_seed", "post_table_seeder.PostTableSeeder")], + ) + + def test_class_option_accepts_dotted_path(self): + output = self._run("--class custom.MySeeder") + + self.assertIn("MySeeder seeded!", output) + seeder = FakeSeeder.instances[-1] + self.assertEqual(seeder.calls, [("run_specific_seed", "custom.MySeeder")]) + + def test_connection_and_directory_options_are_forwarded(self): + self._run("--connection sqlite --directory db/seeds") + + seeder = FakeSeeder.instances[-1] + self.assertEqual(seeder.seed_path, "db/seeds") + self.assertEqual(seeder.connection, "sqlite") + + +if __name__ == "__main__": + unittest.main() diff --git a/fastapi_startkit/tests/masoniteorm/commands/test_make_commands.py b/fastapi_startkit/tests/masoniteorm/commands/test_make_commands.py new file mode 100644 index 00000000..6f36fe88 --- /dev/null +++ b/fastapi_startkit/tests/masoniteorm/commands/test_make_commands.py @@ -0,0 +1,170 @@ +import os +import shutil +import tempfile +import unittest + +from cleo.testers.command_tester import CommandTester + +from fastapi_startkit.masoniteorm.commands.MakeMigrationCommand import MakeMigrationCommand +from fastapi_startkit.masoniteorm.commands.MakeModelCommand import MakeModelCommand +from fastapi_startkit.masoniteorm.commands.MakeObserverCommand import MakeObserverCommand +from fastapi_startkit.masoniteorm.commands.MakeSeedCommand import MakeSeedCommand + + +class _TempCwdTestCase(unittest.TestCase): + """Base case that runs each test inside an isolated temp working directory. + + The make/generator commands write files relative to ``os.getcwd()``, so the + working directory is swapped for a throwaway one to keep the repo clean. + """ + + def setUp(self): + self._original_cwd = os.getcwd() + self._tmp_dir = tempfile.mkdtemp() + os.chdir(self._tmp_dir) + + def tearDown(self): + os.chdir(self._original_cwd) + shutil.rmtree(self._tmp_dir, ignore_errors=True) + + def _tester(self, command_class): + return CommandTester(command_class()) + + def _read_single_file(self, directory): + files = [f for f in os.listdir(directory) if f.endswith(".py")] + self.assertEqual(len(files), 1, f"expected one generated file in {directory}, got {files}") + with open(os.path.join(directory, files[0])) as fp: + return files[0], fp.read() + + +class TestMakeMigrationCommand(_TempCwdTestCase): + def test_creates_migration_with_inferred_table(self): + os.makedirs("databases/migrations") + tester = self._tester(MakeMigrationCommand) + tester.execute("create_posts_table") + + output = tester.io.fetch_output() + self.assertIn("Migration file created:", output) + + file_name, content = self._read_single_file("databases/migrations") + self.assertTrue(file_name.endswith("_create_posts_table.py")) + self.assertIn("class CreatePostsTable(Migration)", content) + self.assertIn('self.schema.create("posts")', content) + + def test_create_option_uses_create_stub(self): + os.makedirs("databases/migrations") + tester = self._tester(MakeMigrationCommand) + tester.execute("add_users --create users") + + _, content = self._read_single_file("databases/migrations") + self.assertIn('self.schema.create("users")', content) + + def test_table_option_uses_table_stub(self): + os.makedirs("databases/migrations") + tester = self._tester(MakeMigrationCommand) + tester.execute("modify_users --table users") + + _, content = self._read_single_file("databases/migrations") + self.assertIn('self.schema.table("users")', content) + self.assertNotIn("self.schema.create", content) + + def test_custom_directory_option(self): + os.makedirs("custom/migrations") + tester = self._tester(MakeMigrationCommand) + tester.execute("create_posts_table --directory custom/migrations") + + output = tester.io.fetch_output() + self.assertIn("custom/migrations", output) + self.assertTrue(any(f.endswith(".py") for f in os.listdir("custom/migrations"))) + + +class TestMakeModelCommand(_TempCwdTestCase): + def test_creates_model_file(self): + os.makedirs("app") + tester = self._tester(MakeModelCommand) + tester.execute("Post") + + output = tester.io.fetch_output() + self.assertIn("Model created:", output) + + content = open(os.path.join("app", "Post.py")).read() + self.assertIn("class Post(Model)", content) + + def test_pep_option_uses_underscore_filename(self): + os.makedirs("app") + tester = self._tester(MakeModelCommand) + tester.execute("BlogPost --pep") + + self.assertTrue(os.path.exists(os.path.join("app", "blog_post.py"))) + + def test_reports_when_model_already_exists(self): + os.makedirs("app") + self._tester(MakeModelCommand).execute("Post") + + tester = self._tester(MakeModelCommand) + tester.execute("Post") + output = tester.io.fetch_output() + self.assertIn('Model "Post" Already Exists', output) + + def test_custom_directory_option(self): + os.makedirs("models") + tester = self._tester(MakeModelCommand) + tester.execute("Post --directory models") + + self.assertTrue(os.path.exists(os.path.join("models", "Post.py"))) + + +class TestMakeSeedCommand(_TempCwdTestCase): + def test_creates_seed_file(self): + os.makedirs("databases/seeds") + tester = self._tester(MakeSeedCommand) + tester.execute("Post") + + output = tester.io.fetch_output() + self.assertIn("Seed file created:", output) + + content = open(os.path.join("databases/seeds", "post_table_seeder.py")).read() + self.assertIn("class PostTableSeeder(Seeder)", content) + + def test_reports_when_seed_already_exists(self): + os.makedirs("databases/seeds") + self._tester(MakeSeedCommand).execute("Post") + + tester = self._tester(MakeSeedCommand) + tester.execute("Post") + output = tester.io.fetch_output() + self.assertIn("already exists.", output) + + +class TestMakeObserverCommand(_TempCwdTestCase): + def test_creates_observer_file(self): + tester = self._tester(MakeObserverCommand) + tester.execute("Post") + + output = tester.io.fetch_output() + self.assertIn("Observer created:", output) + + content = open(os.path.join("app/observers", "PostObserver.py")).read() + self.assertIn("class PostObserver:", content) + self.assertIn("def created(self, post):", content) + + def test_model_option_controls_variable_and_type(self): + tester = self._tester(MakeObserverCommand) + tester.execute("Audit --model User") + + content = open(os.path.join("app/observers", "AuditObserver.py")).read() + self.assertIn("class AuditObserver:", content) + self.assertIn("def created(self, user):", content) + self.assertIn("User model.", content) + + def test_reports_when_observer_already_exists(self): + self._tester(MakeObserverCommand).execute("Post") + + tester = self._tester(MakeObserverCommand) + tester.execute("Post") + output = tester.io.fetch_output() + self.assertIn('Observer "Post" Already Exists', output) + + +if __name__ == "__main__": + unittest.main() diff --git a/fastapi_startkit/tests/masoniteorm/commands/test_migrate_commands.py b/fastapi_startkit/tests/masoniteorm/commands/test_migrate_commands.py index 64f7614e..3aca1a07 100644 --- a/fastapi_startkit/tests/masoniteorm/commands/test_migrate_commands.py +++ b/fastapi_startkit/tests/masoniteorm/commands/test_migrate_commands.py @@ -5,6 +5,7 @@ from fastapi_startkit.masoniteorm.commands.DBMigrateCommand import DBMigrateCommand from fastapi_startkit.masoniteorm.commands.MigrateFreshCommand import MigrateFreshCommand +from fastapi_startkit.masoniteorm.commands.MigrateRefreshCommand import MigrateRefreshCommand from fastapi_startkit.masoniteorm.commands.MigrateResetCommand import MigrateResetCommand from fastapi_startkit.masoniteorm.commands.MigrateRollbackCommand import MigrateRollbackCommand from fastapi_startkit.masoniteorm.commands.MigrateStatusCommand import MigrateStatusCommand @@ -103,6 +104,17 @@ def test_reset_rolls_back_all_migrations(self): self.assertIn("Rolled back:", output) self.assertIn("create_posts_table", output) + def test_refresh_resets_and_remigrates(self): + asyncio.run(self._migrate()) + + cmd = self._make_command(MigrateRefreshCommand) + tester = CommandTester(cmd) + tester.execute("--connection sqlite") + output = tester.io.fetch_output() + self.assertIn("Rolled back:", output) + self.assertIn("Migrated:", output) + self.assertIn("create_posts_table", output) + def test_fresh_drops_all_tables_and_remigrates(self): asyncio.run(self._migrate()) From 5902207ba6ced607dee4856955800981fdbdc822 Mon Sep 17 00:00:00 2001 From: Bedram Tamang Date: Mon, 13 Jul 2026 22:51:03 -0700 Subject: [PATCH 2/7] fix: update seeder mock patch target after seeds->seeders rename --- .../tests/masoniteorm/commands/test_db_seed_command.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fastapi_startkit/tests/masoniteorm/commands/test_db_seed_command.py b/fastapi_startkit/tests/masoniteorm/commands/test_db_seed_command.py index ebc40860..9d5dd547 100644 --- a/fastapi_startkit/tests/masoniteorm/commands/test_db_seed_command.py +++ b/fastapi_startkit/tests/masoniteorm/commands/test_db_seed_command.py @@ -28,7 +28,7 @@ class TestDBSeedCommand(unittest.TestCase): def setUp(self): FakeSeeder.instances = [] patcher = mock.patch( - "fastapi_startkit.masoniteorm.seeds.Seeder", + "fastapi_startkit.masoniteorm.seeders.Seeder", FakeSeeder, ) patcher.start() From 6234726088280fd52b5c6677d88503f49cdc68be Mon Sep 17 00:00:00 2001 From: Bedram Tamang Date: Mon, 13 Jul 2026 22:52:02 -0700 Subject: [PATCH 3/7] fix: align seed directory expectations with databases/seeders rename --- .../tests/masoniteorm/commands/test_db_seed_command.py | 2 +- .../tests/masoniteorm/commands/test_make_commands.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/fastapi_startkit/tests/masoniteorm/commands/test_db_seed_command.py b/fastapi_startkit/tests/masoniteorm/commands/test_db_seed_command.py index 9d5dd547..6c54f8ee 100644 --- a/fastapi_startkit/tests/masoniteorm/commands/test_db_seed_command.py +++ b/fastapi_startkit/tests/masoniteorm/commands/test_db_seed_command.py @@ -45,7 +45,7 @@ def test_runs_database_seeder_by_default(self): self.assertIn("Database Seeder seeded!", output) seeder = FakeSeeder.instances[-1] self.assertEqual(seeder.calls, [("run_database_seed", None)]) - self.assertEqual(seeder.seed_path, "databases/seeds") + self.assertEqual(seeder.seed_path, "databases/seeders") self.assertEqual(seeder.connection, "default") def test_seeds_specific_table_from_argument(self): diff --git a/fastapi_startkit/tests/masoniteorm/commands/test_make_commands.py b/fastapi_startkit/tests/masoniteorm/commands/test_make_commands.py index 6f36fe88..05ee65f7 100644 --- a/fastapi_startkit/tests/masoniteorm/commands/test_make_commands.py +++ b/fastapi_startkit/tests/masoniteorm/commands/test_make_commands.py @@ -116,18 +116,18 @@ def test_custom_directory_option(self): class TestMakeSeedCommand(_TempCwdTestCase): def test_creates_seed_file(self): - os.makedirs("databases/seeds") + os.makedirs("databases/seeders") tester = self._tester(MakeSeedCommand) tester.execute("Post") output = tester.io.fetch_output() self.assertIn("Seed file created:", output) - content = open(os.path.join("databases/seeds", "post_table_seeder.py")).read() + content = open(os.path.join("databases/seeders", "post_table_seeder.py")).read() self.assertIn("class PostTableSeeder(Seeder)", content) def test_reports_when_seed_already_exists(self): - os.makedirs("databases/seeds") + os.makedirs("databases/seeders") self._tester(MakeSeedCommand).execute("Post") tester = self._tester(MakeSeedCommand) From 6b01e08d425e9f064b5983e2f3ad639f88e2dbd0 Mon Sep 17 00:00:00 2001 From: Bedram Tamang Date: Mon, 13 Jul 2026 23:16:52 -0700 Subject: [PATCH 4/7] test(orm): refactor DBSeedCommand tests to class-based app-driven style Consolidate test_db_seed_command.py and test_seed_commands.py into a single TestDBSeedCommand(unittest.TestCase). setUp now builds a real Application via fixtures.app.create_app(); option/argument-resolution tests keep driving the command directly through CommandTester (needed for the mocked-Seeder assertions and for the exception paths, since the console app swallows command exceptions), while the fixture-backed end-to-end tests now run through self.app.run("db:seed", ...). --- .../commands/test_db_seed_command.py | 110 +++++++++++++++--- .../commands/test_seed_commands.py | 87 -------------- 2 files changed, 96 insertions(+), 101 deletions(-) delete mode 100644 fastapi_startkit/tests/masoniteorm/commands/test_seed_commands.py diff --git a/fastapi_startkit/tests/masoniteorm/commands/test_db_seed_command.py b/fastapi_startkit/tests/masoniteorm/commands/test_db_seed_command.py index 6c54f8ee..4a376829 100644 --- a/fastapi_startkit/tests/masoniteorm/commands/test_db_seed_command.py +++ b/fastapi_startkit/tests/masoniteorm/commands/test_db_seed_command.py @@ -1,17 +1,23 @@ +import io import unittest +from contextlib import redirect_stdout from unittest import mock from cleo.testers.command_tester import CommandTester from fastapi_startkit.masoniteorm.commands.DBSeedCommand import DBSeedCommand +from .fixtures.databases.seeders.recorder import CALLS + +FIXTURE_SEED_PATH = "tests.masoniteorm.commands.fixtures.databases.seeders" + class FakeSeeder: """Records constructor args and awaited methods, mocking DB side effects.""" instances = [] - def __init__(self, seed_path="databases/seeds", connection=None): + def __init__(self, seed_path="databases/seeders", connection=None): self.seed_path = seed_path self.connection = connection self.calls = [] @@ -26,21 +32,31 @@ async def run_specific_seed(self, seed): class TestDBSeedCommand(unittest.TestCase): def setUp(self): - FakeSeeder.instances = [] - patcher = mock.patch( - "fastapi_startkit.masoniteorm.seeders.Seeder", - FakeSeeder, - ) - patcher.start() - self.addCleanup(patcher.stop) + from .fixtures.app import create_app + + self.app = create_app() + CALLS.clear() + + def tearDown(self): + CALLS.clear() def _run(self, args=""): tester = CommandTester(DBSeedCommand()) tester.execute(args) return tester.io.fetch_output() + def _run_app(self, args=""): + buffer = io.StringIO() + with redirect_stdout(buffer): + self.app.run("db:seed", args) + return buffer.getvalue() + + # -- option/argument resolution, exercised against a mocked Seeder -- + def test_runs_database_seeder_by_default(self): - output = self._run("") + FakeSeeder.instances = [] + with mock.patch("fastapi_startkit.masoniteorm.seeders.Seeder", FakeSeeder): + output = self._run("") self.assertIn("Database Seeder seeded!", output) seeder = FakeSeeder.instances[-1] @@ -49,7 +65,9 @@ def test_runs_database_seeder_by_default(self): self.assertEqual(seeder.connection, "default") def test_seeds_specific_table_from_argument(self): - output = self._run("posts") + FakeSeeder.instances = [] + with mock.patch("fastapi_startkit.masoniteorm.seeders.Seeder", FakeSeeder): + output = self._run("posts") self.assertIn("PostsTableSeeder seeded!", output) seeder = FakeSeeder.instances[-1] @@ -59,7 +77,9 @@ def test_seeds_specific_table_from_argument(self): ) def test_class_option_resolves_plain_class_name(self): - output = self._run("--class PostSeeder") + FakeSeeder.instances = [] + with mock.patch("fastapi_startkit.masoniteorm.seeders.Seeder", FakeSeeder): + output = self._run("--class PostSeeder") self.assertIn("PostSeeder seeded!", output) seeder = FakeSeeder.instances[-1] @@ -69,7 +89,9 @@ def test_class_option_resolves_plain_class_name(self): ) def test_class_option_resolves_table_seeder_suffix(self): - output = self._run("--class PostTableSeeder") + FakeSeeder.instances = [] + with mock.patch("fastapi_startkit.masoniteorm.seeders.Seeder", FakeSeeder): + output = self._run("--class PostTableSeeder") self.assertIn("PostTableSeeder seeded!", output) seeder = FakeSeeder.instances[-1] @@ -79,19 +101,79 @@ def test_class_option_resolves_table_seeder_suffix(self): ) def test_class_option_accepts_dotted_path(self): - output = self._run("--class custom.MySeeder") + FakeSeeder.instances = [] + with mock.patch("fastapi_startkit.masoniteorm.seeders.Seeder", FakeSeeder): + output = self._run("--class custom.MySeeder") self.assertIn("MySeeder seeded!", output) seeder = FakeSeeder.instances[-1] self.assertEqual(seeder.calls, [("run_specific_seed", "custom.MySeeder")]) def test_connection_and_directory_options_are_forwarded(self): - self._run("--connection sqlite --directory db/seeds") + FakeSeeder.instances = [] + with mock.patch("fastapi_startkit.masoniteorm.seeders.Seeder", FakeSeeder): + self._run("--connection sqlite --directory db/seeds") seeder = FakeSeeder.instances[-1] self.assertEqual(seeder.seed_path, "db/seeds") self.assertEqual(seeder.connection, "sqlite") + # -- end-to-end, driven through the registered console app against real fixture seeders -- + + def test_runs_database_seeder_by_default_via_app(self): + output = self._run_app(f"--directory {FIXTURE_SEED_PATH} --connection sqlite") + + self.assertIn("Database Seeder seeded!", output) + self.assertEqual(CALLS, [("database", "sqlite")]) + + def test_runs_seeder_for_table_argument_via_app(self): + output = self._run_app(f"user --directory {FIXTURE_SEED_PATH} --connection sqlite") + + self.assertIn("UserTableSeeder seeded!", output) + self.assertEqual(CALLS, [("user_table", "sqlite")]) + + def test_runs_seeder_for_class_option_without_table_suffix_via_app(self): + output = self._run_app(f"--directory {FIXTURE_SEED_PATH} --class SampleSeeder --connection sqlite") + + self.assertIn("SampleSeeder seeded!", output) + self.assertEqual(CALLS, [("sample", "sqlite")]) + + def test_runs_seeder_for_class_option_with_table_suffix_via_app(self): + output = self._run_app(f"--directory {FIXTURE_SEED_PATH} --class UserTableSeeder --connection sqlite") + + self.assertIn("UserTableSeeder seeded!", output) + self.assertEqual(CALLS, [("user_table", "sqlite")]) + + def test_runs_seeder_for_fully_qualified_class_option_via_app(self): + output = self._run_app( + f"--directory {FIXTURE_SEED_PATH} --class special_seeder.SpecialSeeder --connection sqlite" + ) + + self.assertIn("SpecialSeeder seeded!", output) + self.assertEqual(CALLS, [("special", "sqlite")]) + + def test_class_option_takes_precedence_over_table_argument_via_app(self): + output = self._run_app(f"user --directory {FIXTURE_SEED_PATH} --class SampleSeeder --connection sqlite") + + self.assertIn("SampleSeeder seeded!", output) + self.assertEqual(CALLS, [("sample", "sqlite")]) + + def test_uses_default_connection_option_via_app(self): + self._run_app(f"--directory {FIXTURE_SEED_PATH}") + + self.assertEqual(CALLS, [("database", "default")]) + + # -- error paths: driven directly through CommandTester, since the console + # application catches command exceptions instead of propagating them -- + + def test_raises_when_seeder_class_cannot_be_found(self): + with self.assertRaises(ValueError): + self._run(f"--directory {FIXTURE_SEED_PATH} --class does_not_exist.NopeSeeder --connection sqlite") + + def test_raises_when_database_seeder_missing_from_directory(self): + with self.assertRaises(ValueError): + self._run("--directory tests.masoniteorm.commands.fixtures.databases.migrations --connection sqlite") + if __name__ == "__main__": unittest.main() diff --git a/fastapi_startkit/tests/masoniteorm/commands/test_seed_commands.py b/fastapi_startkit/tests/masoniteorm/commands/test_seed_commands.py deleted file mode 100644 index c1559d3b..00000000 --- a/fastapi_startkit/tests/masoniteorm/commands/test_seed_commands.py +++ /dev/null @@ -1,87 +0,0 @@ -import unittest - -from cleo.testers.command_tester import CommandTester - -from fastapi_startkit.masoniteorm.commands.DBSeedCommand import DBSeedCommand - -from .fixtures.databases.seeders.recorder import CALLS - -FIXTURE_SEED_PATH = "tests.masoniteorm.commands.fixtures.databases.seeders" - - -class TestDBSeedCommand(unittest.TestCase): - def setUp(self): - CALLS.clear() - - def tearDown(self): - CALLS.clear() - - def _make_tester(self): - command = DBSeedCommand() - return CommandTester(command) - - def test_runs_database_seeder_by_default(self): - tester = self._make_tester() - tester.execute(f"--directory {FIXTURE_SEED_PATH} --connection sqlite") - - output = tester.io.fetch_output() - self.assertIn("Database Seeder seeded!", output) - self.assertEqual(CALLS, [("database", "sqlite")]) - - def test_runs_seeder_for_table_argument(self): - tester = self._make_tester() - tester.execute(f"user --directory {FIXTURE_SEED_PATH} --connection sqlite") - - output = tester.io.fetch_output() - self.assertIn("UserTableSeeder seeded!", output) - self.assertEqual(CALLS, [("user_table", "sqlite")]) - - def test_runs_seeder_for_class_option_without_table_suffix(self): - tester = self._make_tester() - tester.execute(f"--directory {FIXTURE_SEED_PATH} --class SampleSeeder --connection sqlite") - - output = tester.io.fetch_output() - self.assertIn("SampleSeeder seeded!", output) - self.assertEqual(CALLS, [("sample", "sqlite")]) - - def test_runs_seeder_for_class_option_with_table_suffix(self): - tester = self._make_tester() - tester.execute(f"--directory {FIXTURE_SEED_PATH} --class UserTableSeeder --connection sqlite") - - output = tester.io.fetch_output() - self.assertIn("UserTableSeeder seeded!", output) - self.assertEqual(CALLS, [("user_table", "sqlite")]) - - def test_runs_seeder_for_fully_qualified_class_option(self): - tester = self._make_tester() - tester.execute(f"--directory {FIXTURE_SEED_PATH} --class special_seeder.SpecialSeeder --connection sqlite") - - output = tester.io.fetch_output() - self.assertIn("SpecialSeeder seeded!", output) - self.assertEqual(CALLS, [("special", "sqlite")]) - - def test_class_option_takes_precedence_over_table_argument(self): - tester = self._make_tester() - tester.execute(f"user --directory {FIXTURE_SEED_PATH} --class SampleSeeder --connection sqlite") - - output = tester.io.fetch_output() - self.assertIn("SampleSeeder seeded!", output) - self.assertEqual(CALLS, [("sample", "sqlite")]) - - def test_raises_when_seeder_class_cannot_be_found(self): - tester = self._make_tester() - - with self.assertRaises(ValueError): - tester.execute(f"--directory {FIXTURE_SEED_PATH} --class does_not_exist.NopeSeeder --connection sqlite") - - def test_raises_when_database_seeder_missing_from_directory(self): - tester = self._make_tester() - - with self.assertRaises(ValueError): - tester.execute("--directory tests.masoniteorm.commands.fixtures.databases.migrations --connection sqlite") - - def test_uses_default_connection_option(self): - tester = self._make_tester() - tester.execute(f"--directory {FIXTURE_SEED_PATH}") - - self.assertEqual(CALLS, [("database", "default")]) From 88b55aa68ec6dc8c2a8c0d0e8eeb322b2ccbc4d3 Mon Sep 17 00:00:00 2001 From: Bedram Tamang Date: Mon, 13 Jul 2026 23:43:46 -0700 Subject: [PATCH 5/7] test(orm): remove FakeSeeder test double, assert via autospec mock Replace the hand-rolled FakeSeeder class in test_db_seed_command.py with mock.patch(..., autospec=True) against the real Seeder, asserting constructor args and awaited calls directly. Same cleanup already applied on the PR #155 branch (commit 8471bd9) but missed on the experiments seeds->seeders refactor (PR #175). --- .../commands/test_db_seed_command.py | 68 ++++--------------- 1 file changed, 14 insertions(+), 54 deletions(-) diff --git a/fastapi_startkit/tests/masoniteorm/commands/test_db_seed_command.py b/fastapi_startkit/tests/masoniteorm/commands/test_db_seed_command.py index 4a376829..5d0235fc 100644 --- a/fastapi_startkit/tests/masoniteorm/commands/test_db_seed_command.py +++ b/fastapi_startkit/tests/masoniteorm/commands/test_db_seed_command.py @@ -12,24 +12,6 @@ FIXTURE_SEED_PATH = "tests.masoniteorm.commands.fixtures.databases.seeders" -class FakeSeeder: - """Records constructor args and awaited methods, mocking DB side effects.""" - - instances = [] - - def __init__(self, seed_path="databases/seeders", connection=None): - self.seed_path = seed_path - self.connection = connection - self.calls = [] - FakeSeeder.instances.append(self) - - async def run_database_seed(self): - self.calls.append(("run_database_seed", None)) - - async def run_specific_seed(self, seed): - self.calls.append(("run_specific_seed", seed)) - - class TestDBSeedCommand(unittest.TestCase): def setUp(self): from .fixtures.app import create_app @@ -54,69 +36,47 @@ def _run_app(self, args=""): # -- option/argument resolution, exercised against a mocked Seeder -- def test_runs_database_seeder_by_default(self): - FakeSeeder.instances = [] - with mock.patch("fastapi_startkit.masoniteorm.seeders.Seeder", FakeSeeder): + with mock.patch("fastapi_startkit.masoniteorm.seeders.Seeder", autospec=True) as MockSeeder: output = self._run("") self.assertIn("Database Seeder seeded!", output) - seeder = FakeSeeder.instances[-1] - self.assertEqual(seeder.calls, [("run_database_seed", None)]) - self.assertEqual(seeder.seed_path, "databases/seeders") - self.assertEqual(seeder.connection, "default") + MockSeeder.assert_called_once_with(seed_path="databases/seeders", connection="default") + MockSeeder.return_value.run_database_seed.assert_awaited_once_with() + MockSeeder.return_value.run_specific_seed.assert_not_awaited() def test_seeds_specific_table_from_argument(self): - FakeSeeder.instances = [] - with mock.patch("fastapi_startkit.masoniteorm.seeders.Seeder", FakeSeeder): + with mock.patch("fastapi_startkit.masoniteorm.seeders.Seeder", autospec=True) as MockSeeder: output = self._run("posts") self.assertIn("PostsTableSeeder seeded!", output) - seeder = FakeSeeder.instances[-1] - self.assertEqual( - seeder.calls, - [("run_specific_seed", "posts_table_seeder.PostsTableSeeder")], - ) + MockSeeder.return_value.run_specific_seed.assert_awaited_once_with("posts_table_seeder.PostsTableSeeder") def test_class_option_resolves_plain_class_name(self): - FakeSeeder.instances = [] - with mock.patch("fastapi_startkit.masoniteorm.seeders.Seeder", FakeSeeder): + with mock.patch("fastapi_startkit.masoniteorm.seeders.Seeder", autospec=True) as MockSeeder: output = self._run("--class PostSeeder") self.assertIn("PostSeeder seeded!", output) - seeder = FakeSeeder.instances[-1] - self.assertEqual( - seeder.calls, - [("run_specific_seed", "post_seeder.PostSeeder")], - ) + MockSeeder.return_value.run_specific_seed.assert_awaited_once_with("post_seeder.PostSeeder") def test_class_option_resolves_table_seeder_suffix(self): - FakeSeeder.instances = [] - with mock.patch("fastapi_startkit.masoniteorm.seeders.Seeder", FakeSeeder): + with mock.patch("fastapi_startkit.masoniteorm.seeders.Seeder", autospec=True) as MockSeeder: output = self._run("--class PostTableSeeder") self.assertIn("PostTableSeeder seeded!", output) - seeder = FakeSeeder.instances[-1] - self.assertEqual( - seeder.calls, - [("run_specific_seed", "post_table_seeder.PostTableSeeder")], - ) + MockSeeder.return_value.run_specific_seed.assert_awaited_once_with("post_table_seeder.PostTableSeeder") def test_class_option_accepts_dotted_path(self): - FakeSeeder.instances = [] - with mock.patch("fastapi_startkit.masoniteorm.seeders.Seeder", FakeSeeder): + with mock.patch("fastapi_startkit.masoniteorm.seeders.Seeder", autospec=True) as MockSeeder: output = self._run("--class custom.MySeeder") self.assertIn("MySeeder seeded!", output) - seeder = FakeSeeder.instances[-1] - self.assertEqual(seeder.calls, [("run_specific_seed", "custom.MySeeder")]) + MockSeeder.return_value.run_specific_seed.assert_awaited_once_with("custom.MySeeder") def test_connection_and_directory_options_are_forwarded(self): - FakeSeeder.instances = [] - with mock.patch("fastapi_startkit.masoniteorm.seeders.Seeder", FakeSeeder): + with mock.patch("fastapi_startkit.masoniteorm.seeders.Seeder", autospec=True) as MockSeeder: self._run("--connection sqlite --directory db/seeds") - seeder = FakeSeeder.instances[-1] - self.assertEqual(seeder.seed_path, "db/seeds") - self.assertEqual(seeder.connection, "sqlite") + MockSeeder.assert_called_once_with(seed_path="db/seeds", connection="sqlite") # -- end-to-end, driven through the registered console app against real fixture seeders -- From f42e957d86fdd81c4f396379033d4ff58ccb5d00 Mon Sep 17 00:00:00 2001 From: Bedram Tamang Date: Mon, 13 Jul 2026 23:56:50 -0700 Subject: [PATCH 6/7] test(orm): rewrite seed/migrate command tests to real sqlite DB integration style Replace mock/output-based assertions with real database state checks, mirroring Laravel's MigratorTest philosophy: boot the real app, run the real command against a real sqlite connection, and assert what actually changed in the database rather than what was printed or called. Seed-command tests: fixture seeder classes now insert real rows into a 'seed_users' table via a real Model; tests assert the resulting rows instead of console output or an in-memory call recorder. Migrate-command tests: assert real schema state (has_table/has_column) and the real migrations tracking table instead of substring-matching command output. Fixes a latent bug found along the way: SQLitePlatform.compile_column_exists queried information_schema.columns, which sqlite does not support, making Schema.has_column() unusable on sqlite. Switched to pragma_table_info(). --- .../schema/platforms/SQLitePlatform.py | 4 +- .../databases/seeders/database_seeder.py | 4 +- .../fixtures/databases/seeders/recorder.py | 3 - .../databases/seeders/sample_seeder.py | 4 +- .../databases/seeders/special_seeder.py | 4 +- .../databases/seeders/user_table_seeder.py | 4 +- .../masoniteorm/commands/fixtures/models.py | 10 ++ .../commands/test_db_seed_command.py | 142 +++++++----------- .../commands/test_migrate_commands.py | 132 ++++++++++------ .../schema/test_sqlite_schema_builder.py | 2 +- 10 files changed, 162 insertions(+), 147 deletions(-) delete mode 100644 fastapi_startkit/tests/masoniteorm/commands/fixtures/databases/seeders/recorder.py create mode 100644 fastapi_startkit/tests/masoniteorm/commands/fixtures/models.py diff --git a/fastapi_startkit/src/fastapi_startkit/masoniteorm/schema/platforms/SQLitePlatform.py b/fastapi_startkit/src/fastapi_startkit/masoniteorm/schema/platforms/SQLitePlatform.py index f640de5c..354e0b14 100644 --- a/fastapi_startkit/src/fastapi_startkit/masoniteorm/schema/platforms/SQLitePlatform.py +++ b/fastapi_startkit/src/fastapi_startkit/masoniteorm/schema/platforms/SQLitePlatform.py @@ -418,9 +418,7 @@ def compile_table_exists(self, table, database=None, schema=None): return f"SELECT name FROM sqlite_master WHERE type='table' AND name='{table}'" def compile_column_exists(self, table, column): - return ( - f"SELECT column_name FROM information_schema.columns WHERE table_name='{table}' and column_name='{column}'" - ) + return f"SELECT name FROM pragma_table_info('{table}') WHERE name='{column}'" def compile_get_all_tables(self, database, schema=None): return "SELECT name FROM sqlite_master WHERE type='table'" diff --git a/fastapi_startkit/tests/masoniteorm/commands/fixtures/databases/seeders/database_seeder.py b/fastapi_startkit/tests/masoniteorm/commands/fixtures/databases/seeders/database_seeder.py index 6460ecd7..916c8614 100644 --- a/fastapi_startkit/tests/masoniteorm/commands/fixtures/databases/seeders/database_seeder.py +++ b/fastapi_startkit/tests/masoniteorm/commands/fixtures/databases/seeders/database_seeder.py @@ -1,8 +1,8 @@ from fastapi_startkit.masoniteorm.seeders import Seeder -from .recorder import CALLS +from ...models import SeededUser class DatabaseSeeder(Seeder): async def run(self): - CALLS.append(("database", self.connection)) + await SeededUser.create({"name": "database-seeder"}) diff --git a/fastapi_startkit/tests/masoniteorm/commands/fixtures/databases/seeders/recorder.py b/fastapi_startkit/tests/masoniteorm/commands/fixtures/databases/seeders/recorder.py deleted file mode 100644 index 0d32f16f..00000000 --- a/fastapi_startkit/tests/masoniteorm/commands/fixtures/databases/seeders/recorder.py +++ /dev/null @@ -1,3 +0,0 @@ -"""Shared call recorder used by the fixture seeder classes below.""" - -CALLS = [] diff --git a/fastapi_startkit/tests/masoniteorm/commands/fixtures/databases/seeders/sample_seeder.py b/fastapi_startkit/tests/masoniteorm/commands/fixtures/databases/seeders/sample_seeder.py index aa19a2b1..d6d4d255 100644 --- a/fastapi_startkit/tests/masoniteorm/commands/fixtures/databases/seeders/sample_seeder.py +++ b/fastapi_startkit/tests/masoniteorm/commands/fixtures/databases/seeders/sample_seeder.py @@ -1,8 +1,8 @@ from fastapi_startkit.masoniteorm.seeders import Seeder -from .recorder import CALLS +from ...models import SeededUser class SampleSeeder(Seeder): async def run(self): - CALLS.append(("sample", self.connection)) + await SeededUser.create({"name": "sample-seeder"}) diff --git a/fastapi_startkit/tests/masoniteorm/commands/fixtures/databases/seeders/special_seeder.py b/fastapi_startkit/tests/masoniteorm/commands/fixtures/databases/seeders/special_seeder.py index d51a277d..7da78334 100644 --- a/fastapi_startkit/tests/masoniteorm/commands/fixtures/databases/seeders/special_seeder.py +++ b/fastapi_startkit/tests/masoniteorm/commands/fixtures/databases/seeders/special_seeder.py @@ -1,8 +1,8 @@ from fastapi_startkit.masoniteorm.seeders import Seeder -from .recorder import CALLS +from ...models import SeededUser class SpecialSeeder(Seeder): async def run(self): - CALLS.append(("special", self.connection)) + await SeededUser.create({"name": "special-seeder"}) diff --git a/fastapi_startkit/tests/masoniteorm/commands/fixtures/databases/seeders/user_table_seeder.py b/fastapi_startkit/tests/masoniteorm/commands/fixtures/databases/seeders/user_table_seeder.py index 250278f6..81a9c7f5 100644 --- a/fastapi_startkit/tests/masoniteorm/commands/fixtures/databases/seeders/user_table_seeder.py +++ b/fastapi_startkit/tests/masoniteorm/commands/fixtures/databases/seeders/user_table_seeder.py @@ -1,8 +1,8 @@ from fastapi_startkit.masoniteorm.seeders import Seeder -from .recorder import CALLS +from ...models import SeededUser class UserTableSeeder(Seeder): async def run(self): - CALLS.append(("user_table", self.connection)) + await SeededUser.create({"name": "user-table-seeder"}) diff --git a/fastapi_startkit/tests/masoniteorm/commands/fixtures/models.py b/fastapi_startkit/tests/masoniteorm/commands/fixtures/models.py new file mode 100644 index 00000000..e3fc9fdc --- /dev/null +++ b/fastapi_startkit/tests/masoniteorm/commands/fixtures/models.py @@ -0,0 +1,10 @@ +from fastapi_startkit.masoniteorm.models.model import Model + + +class SeededUser(Model): + """Real model backing the sqlite table the fixture seeders write into.""" + + __table__ = "seed_users" + __timestamps__ = False + + name: str diff --git a/fastapi_startkit/tests/masoniteorm/commands/test_db_seed_command.py b/fastapi_startkit/tests/masoniteorm/commands/test_db_seed_command.py index 5d0235fc..76289703 100644 --- a/fastapi_startkit/tests/masoniteorm/commands/test_db_seed_command.py +++ b/fastapi_startkit/tests/masoniteorm/commands/test_db_seed_command.py @@ -1,127 +1,101 @@ -import io +import asyncio import unittest -from contextlib import redirect_stdout -from unittest import mock from cleo.testers.command_tester import CommandTester from fastapi_startkit.masoniteorm.commands.DBSeedCommand import DBSeedCommand -from .fixtures.databases.seeders.recorder import CALLS +from .fixtures.app import create_app, DB_PATH +from .fixtures.models import SeededUser FIXTURE_SEED_PATH = "tests.masoniteorm.commands.fixtures.databases.seeders" class TestDBSeedCommand(unittest.TestCase): - def setUp(self): - from .fixtures.app import create_app + @classmethod + def setUpClass(cls): + cls.app = create_app() - self.app = create_app() - CALLS.clear() + def setUp(self): + asyncio.run(self._reset_table()) def tearDown(self): - CALLS.clear() + asyncio.run(self._drop_table()) + if DB_PATH.exists(): + DB_PATH.unlink() + + async def _reset_table(self): + db = self.app.make("db") + await db.clear() + schema = db.get_schema_builder() + await schema.drop_table_if_exists("seed_users") + async with await schema.create("seed_users") as table: + table.id() + table.string("name") + + async def _drop_table(self): + db = self.app.make("db") + schema = db.get_schema_builder() + await schema.drop_table_if_exists("seed_users") + await db.clear() def _run(self, args=""): tester = CommandTester(DBSeedCommand()) tester.execute(args) return tester.io.fetch_output() - def _run_app(self, args=""): - buffer = io.StringIO() - with redirect_stdout(buffer): - self.app.run("db:seed", args) - return buffer.getvalue() + def _seeded_names(self): + return asyncio.run(self._fetch_names()) + + @staticmethod + async def _fetch_names(): + rows = await SeededUser.all() + return sorted(row.name for row in rows) - # -- option/argument resolution, exercised against a mocked Seeder -- + # -- behavior is proven by the rows the real Seeder + real fixture seeder + # classes write into a real sqlite table, not by console output -- def test_runs_database_seeder_by_default(self): - with mock.patch("fastapi_startkit.masoniteorm.seeders.Seeder", autospec=True) as MockSeeder: - output = self._run("") + self._run(f"--directory {FIXTURE_SEED_PATH} --connection sqlite") - self.assertIn("Database Seeder seeded!", output) - MockSeeder.assert_called_once_with(seed_path="databases/seeders", connection="default") - MockSeeder.return_value.run_database_seed.assert_awaited_once_with() - MockSeeder.return_value.run_specific_seed.assert_not_awaited() + self.assertEqual(self._seeded_names(), ["database-seeder"]) def test_seeds_specific_table_from_argument(self): - with mock.patch("fastapi_startkit.masoniteorm.seeders.Seeder", autospec=True) as MockSeeder: - output = self._run("posts") + self._run(f"user --directory {FIXTURE_SEED_PATH} --connection sqlite") - self.assertIn("PostsTableSeeder seeded!", output) - MockSeeder.return_value.run_specific_seed.assert_awaited_once_with("posts_table_seeder.PostsTableSeeder") + self.assertEqual(self._seeded_names(), ["user-table-seeder"]) def test_class_option_resolves_plain_class_name(self): - with mock.patch("fastapi_startkit.masoniteorm.seeders.Seeder", autospec=True) as MockSeeder: - output = self._run("--class PostSeeder") + self._run(f"--directory {FIXTURE_SEED_PATH} --class SampleSeeder --connection sqlite") - self.assertIn("PostSeeder seeded!", output) - MockSeeder.return_value.run_specific_seed.assert_awaited_once_with("post_seeder.PostSeeder") + self.assertEqual(self._seeded_names(), ["sample-seeder"]) def test_class_option_resolves_table_seeder_suffix(self): - with mock.patch("fastapi_startkit.masoniteorm.seeders.Seeder", autospec=True) as MockSeeder: - output = self._run("--class PostTableSeeder") + self._run(f"--directory {FIXTURE_SEED_PATH} --class UserTableSeeder --connection sqlite") - self.assertIn("PostTableSeeder seeded!", output) - MockSeeder.return_value.run_specific_seed.assert_awaited_once_with("post_table_seeder.PostTableSeeder") + self.assertEqual(self._seeded_names(), ["user-table-seeder"]) def test_class_option_accepts_dotted_path(self): - with mock.patch("fastapi_startkit.masoniteorm.seeders.Seeder", autospec=True) as MockSeeder: - output = self._run("--class custom.MySeeder") - - self.assertIn("MySeeder seeded!", output) - MockSeeder.return_value.run_specific_seed.assert_awaited_once_with("custom.MySeeder") + self._run(f"--directory {FIXTURE_SEED_PATH} --class special_seeder.SpecialSeeder --connection sqlite") - def test_connection_and_directory_options_are_forwarded(self): - with mock.patch("fastapi_startkit.masoniteorm.seeders.Seeder", autospec=True) as MockSeeder: - self._run("--connection sqlite --directory db/seeds") - - MockSeeder.assert_called_once_with(seed_path="db/seeds", connection="sqlite") - - # -- end-to-end, driven through the registered console app against real fixture seeders -- - - def test_runs_database_seeder_by_default_via_app(self): - output = self._run_app(f"--directory {FIXTURE_SEED_PATH} --connection sqlite") - - self.assertIn("Database Seeder seeded!", output) - self.assertEqual(CALLS, [("database", "sqlite")]) + self.assertEqual(self._seeded_names(), ["special-seeder"]) - def test_runs_seeder_for_table_argument_via_app(self): - output = self._run_app(f"user --directory {FIXTURE_SEED_PATH} --connection sqlite") + def test_class_option_takes_precedence_over_table_argument(self): + self._run(f"user --directory {FIXTURE_SEED_PATH} --class SampleSeeder --connection sqlite") - self.assertIn("UserTableSeeder seeded!", output) - self.assertEqual(CALLS, [("user_table", "sqlite")]) + self.assertEqual(self._seeded_names(), ["sample-seeder"]) - def test_runs_seeder_for_class_option_without_table_suffix_via_app(self): - output = self._run_app(f"--directory {FIXTURE_SEED_PATH} --class SampleSeeder --connection sqlite") + def test_uses_default_connection_when_not_specified(self): + self._run(f"--directory {FIXTURE_SEED_PATH}") - self.assertIn("SampleSeeder seeded!", output) - self.assertEqual(CALLS, [("sample", "sqlite")]) + self.assertEqual(self._seeded_names(), ["database-seeder"]) - def test_runs_seeder_for_class_option_with_table_suffix_via_app(self): - output = self._run_app(f"--directory {FIXTURE_SEED_PATH} --class UserTableSeeder --connection sqlite") + def test_success_message_names_the_seeder(self): + # Minimal, secondary output check -- the user-facing message contract, + # not a substitute for the row assertions above. + output = self._run(f"--directory {FIXTURE_SEED_PATH} --connection sqlite") - self.assertIn("UserTableSeeder seeded!", output) - self.assertEqual(CALLS, [("user_table", "sqlite")]) - - def test_runs_seeder_for_fully_qualified_class_option_via_app(self): - output = self._run_app( - f"--directory {FIXTURE_SEED_PATH} --class special_seeder.SpecialSeeder --connection sqlite" - ) - - self.assertIn("SpecialSeeder seeded!", output) - self.assertEqual(CALLS, [("special", "sqlite")]) - - def test_class_option_takes_precedence_over_table_argument_via_app(self): - output = self._run_app(f"user --directory {FIXTURE_SEED_PATH} --class SampleSeeder --connection sqlite") - - self.assertIn("SampleSeeder seeded!", output) - self.assertEqual(CALLS, [("sample", "sqlite")]) - - def test_uses_default_connection_option_via_app(self): - self._run_app(f"--directory {FIXTURE_SEED_PATH}") - - self.assertEqual(CALLS, [("database", "default")]) + self.assertIn("Database Seeder seeded!", output) # -- error paths: driven directly through CommandTester, since the console # application catches command exceptions instead of propagating them -- @@ -133,7 +107,3 @@ def test_raises_when_seeder_class_cannot_be_found(self): def test_raises_when_database_seeder_missing_from_directory(self): with self.assertRaises(ValueError): self._run("--directory tests.masoniteorm.commands.fixtures.databases.migrations --connection sqlite") - - -if __name__ == "__main__": - unittest.main() diff --git a/fastapi_startkit/tests/masoniteorm/commands/test_migrate_commands.py b/fastapi_startkit/tests/masoniteorm/commands/test_migrate_commands.py index 3aca1a07..792922c0 100644 --- a/fastapi_startkit/tests/masoniteorm/commands/test_migrate_commands.py +++ b/fastapi_startkit/tests/masoniteorm/commands/test_migrate_commands.py @@ -10,8 +10,13 @@ from fastapi_startkit.masoniteorm.commands.MigrateRollbackCommand import MigrateRollbackCommand from fastapi_startkit.masoniteorm.commands.MigrateStatusCommand import MigrateStatusCommand from fastapi_startkit.masoniteorm.migrations.Migrator import Migrator +from fastapi_startkit.masoniteorm.models.MigrationModel import MigrationModel + from .fixtures.app import create_app, DB_PATH +CREATE_POSTS = "2026_01_01_000000_create_posts_table" +ADD_BODY_TO_POSTS = "2026_01_01_000001_add_body_to_posts_table" + class TestMigrateCommands(unittest.TestCase): @classmethod @@ -19,6 +24,7 @@ def setUpClass(cls): cls.app = create_app() def setUp(self): + self.schema = self.app.make("db").get_schema_builder() asyncio.run(self._reset_db()) def tearDown(self): @@ -30,6 +36,8 @@ async def _reset_db(self): await db.clear() schema = db.get_schema_builder() for table in await schema.get_all_tables(): + if table.startswith("sqlite_"): + continue await schema.drop_table_if_exists(table) async def _migrate(self): @@ -48,80 +56,112 @@ def _make_command(self, command_class): cmd.set_container(self.app) return cmd - def test_migrate_runs_pending_migrations(self): + def _has_table(self, table): + return asyncio.run(self.schema.has_table(table)) + + def _has_column(self, table, column): + return asyncio.run(self.schema.has_column(table, column)) + + def _ran_migrations(self): + return asyncio.run(self._fetch_ran_migrations()) + + @staticmethod + async def _fetch_ran_migrations(): + rows = await MigrationModel.all() + return sorted((row.migration, row.batch) for row in rows) + + # -- behavior is proven by the real sqlite schema and the migrations + # tracking table, not by console output -- + + def test_migrate_creates_table_and_applies_pending_migrations(self): cmd = self._make_command(DBMigrateCommand) - tester = CommandTester(cmd) - tester.execute("--connection sqlite") - output = tester.io.fetch_output() - self.assertIn("Migrated:", output) - self.assertIn("create_posts_table", output) + CommandTester(cmd).execute("--connection sqlite") + + self.assertTrue(self._has_table("posts")) + self.assertTrue(self._has_column("posts", "title")) + self.assertTrue(self._has_column("posts", "body")) + self.assertEqual( + self._ran_migrations(), + [(CREATE_POSTS, 1), (ADD_BODY_TO_POSTS, 1)], + ) - def test_migrate_reports_nothing_to_migrate(self): + def test_migrate_is_idempotent_when_nothing_pending(self): cmd = self._make_command(DBMigrateCommand) - tester = CommandTester(cmd) - tester.execute("--connection sqlite") - tester.io.fetch_output() + CommandTester(cmd).execute("--connection sqlite") + ran_after_first_run = self._ran_migrations() - tester.execute("--connection sqlite") - output = tester.io.fetch_output() - self.assertIn("Nothing To Migrate!", output) + CommandTester(cmd).execute("--connection sqlite") + + self.assertEqual(self._ran_migrations(), ran_after_first_run) + self.assertTrue(self._has_table("posts")) - def test_status_shows_unran_migrations(self): + def test_status_command_creates_tracking_table_without_running_migrations(self): cmd = self._make_command(MigrateStatusCommand) - tester = CommandTester(cmd) - tester.execute("--connection sqlite") - output = tester.io.fetch_output() - self.assertIn("create_posts_table", output) - self.assertIn("N", output) + CommandTester(cmd).execute("--connection sqlite") - def test_status_shows_ran_migrations(self): + self.assertTrue(self._has_table("migrations")) + self.assertFalse(self._has_table("posts")) + self.assertEqual(self._ran_migrations(), []) + + def test_status_command_leaves_state_unchanged_after_migrate(self): asyncio.run(self._migrate()) + ran_before = self._ran_migrations() cmd = self._make_command(MigrateStatusCommand) - tester = CommandTester(cmd) - tester.execute("--connection sqlite") - output = tester.io.fetch_output() - self.assertIn("create_posts_table", output) - self.assertIn("Y", output) + CommandTester(cmd).execute("--connection sqlite") + + self.assertEqual(self._ran_migrations(), ran_before) + self.assertTrue(self._has_table("posts")) - def test_rollback_rolls_back_last_batch(self): + def test_rollback_drops_last_batch(self): asyncio.run(self._migrate()) cmd = self._make_command(MigrateRollbackCommand) - tester = CommandTester(cmd) - tester.execute("--connection sqlite") - output = tester.io.fetch_output() - self.assertIn("Rolled back:", output) - self.assertIn("create_posts_table", output) + CommandTester(cmd).execute("--connection sqlite") + + self.assertEqual(self._ran_migrations(), []) + self.assertFalse(self._has_table("posts")) def test_reset_rolls_back_all_migrations(self): asyncio.run(self._migrate()) cmd = self._make_command(MigrateResetCommand) - tester = CommandTester(cmd) - tester.execute("--connection sqlite") - output = tester.io.fetch_output() - self.assertIn("Rolled back:", output) - self.assertIn("create_posts_table", output) + CommandTester(cmd).execute("--connection sqlite") + + self.assertEqual(self._ran_migrations(), []) + self.assertFalse(self._has_table("posts")) def test_refresh_resets_and_remigrates(self): asyncio.run(self._migrate()) cmd = self._make_command(MigrateRefreshCommand) - tester = CommandTester(cmd) - tester.execute("--connection sqlite") - output = tester.io.fetch_output() - self.assertIn("Rolled back:", output) - self.assertIn("Migrated:", output) - self.assertIn("create_posts_table", output) + CommandTester(cmd).execute("--connection sqlite") + + self.assertTrue(self._has_table("posts")) + self.assertTrue(self._has_column("posts", "body")) + self.assertEqual( + self._ran_migrations(), + [(CREATE_POSTS, 1), (ADD_BODY_TO_POSTS, 1)], + ) def test_fresh_drops_all_tables_and_remigrates(self): asyncio.run(self._migrate()) cmd = self._make_command(MigrateFreshCommand) + CommandTester(cmd).execute("--connection sqlite") + + self.assertTrue(self._has_table("posts")) + self.assertTrue(self._has_column("posts", "body")) + self.assertEqual( + self._ran_migrations(), + [(CREATE_POSTS, 1), (ADD_BODY_TO_POSTS, 1)], + ) + + def test_migrate_command_reports_success_message(self): + # Minimal, secondary output check -- the user-facing message contract, + # not a substitute for the schema-state assertions above. + cmd = self._make_command(DBMigrateCommand) tester = CommandTester(cmd) tester.execute("--connection sqlite") - output = tester.io.fetch_output() - self.assertIn("Dropping all tables", output) - self.assertIn("Migrated:", output) - self.assertIn("create_posts_table", output) + + self.assertIn("Migrated:", tester.io.fetch_output()) diff --git a/fastapi_startkit/tests/masoniteorm/sqlite/schema/test_sqlite_schema_builder.py b/fastapi_startkit/tests/masoniteorm/sqlite/schema/test_sqlite_schema_builder.py index 2da6ef0b..b167c038 100644 --- a/fastapi_startkit/tests/masoniteorm/sqlite/schema/test_sqlite_schema_builder.py +++ b/fastapi_startkit/tests/masoniteorm/sqlite/schema/test_sqlite_schema_builder.py @@ -374,7 +374,7 @@ async def test_has_column(self): sql, _ = mock_select.call_args[0] self.assertEqual( sql, - "SELECT column_name FROM information_schema.columns WHERE table_name='users' and column_name='name'", + "SELECT name FROM pragma_table_info('users') WHERE name='name'", ) async def test_can_have_unsigned_columns(self): From 5a0f58e3608413fa8b718ddedba8499f86e07e48 Mon Sep 17 00:00:00 2001 From: Bedram Tamang Date: Tue, 14 Jul 2026 09:58:05 -0700 Subject: [PATCH 7/7] test(orm): make command tests run generated migrations against real sqlite --- .../commands/test_make_commands.py | 165 ++++++++++++++---- 1 file changed, 134 insertions(+), 31 deletions(-) diff --git a/fastapi_startkit/tests/masoniteorm/commands/test_make_commands.py b/fastapi_startkit/tests/masoniteorm/commands/test_make_commands.py index 05ee65f7..5ad20843 100644 --- a/fastapi_startkit/tests/masoniteorm/commands/test_make_commands.py +++ b/fastapi_startkit/tests/masoniteorm/commands/test_make_commands.py @@ -1,3 +1,5 @@ +import asyncio +import importlib.util import os import shutil import tempfile @@ -9,6 +11,9 @@ from fastapi_startkit.masoniteorm.commands.MakeModelCommand import MakeModelCommand from fastapi_startkit.masoniteorm.commands.MakeObserverCommand import MakeObserverCommand from fastapi_startkit.masoniteorm.commands.MakeSeedCommand import MakeSeedCommand +from fastapi_startkit.masoniteorm.migrations.Migration import Migration + +from .fixtures.app import create_app, DB_PATH class _TempCwdTestCase(unittest.TestCase): @@ -30,55 +35,147 @@ def tearDown(self): def _tester(self, command_class): return CommandTester(command_class()) - def _read_single_file(self, directory): + def _generated_file(self, directory): files = [f for f in os.listdir(directory) if f.endswith(".py")] self.assertEqual(len(files), 1, f"expected one generated file in {directory}, got {files}") - with open(os.path.join(directory, files[0])) as fp: - return files[0], fp.read() + return os.path.join(directory, files[0]) + + def _read_single_file(self, directory): + path = self._generated_file(directory) + with open(path) as fp: + return os.path.basename(path), fp.read() class TestMakeMigrationCommand(_TempCwdTestCase): - def test_creates_migration_with_inferred_table(self): - os.makedirs("databases/migrations") - tester = self._tester(MakeMigrationCommand) - tester.execute("create_posts_table") + """Real sqlite integration tests: a generated migration is actually executed + against a live sqlite database and the resulting schema is inspected, rather + than asserting on the generated file's text. + """ - output = tester.io.fetch_output() - self.assertIn("Migration file created:", output) + @classmethod + def setUpClass(cls): + cls.app = create_app() - file_name, content = self._read_single_file("databases/migrations") - self.assertTrue(file_name.endswith("_create_posts_table.py")) - self.assertIn("class CreatePostsTable(Migration)", content) - self.assertIn('self.schema.create("posts")', content) + def setUp(self): + super().setUp() + asyncio.run(self._reset_db()) - def test_create_option_uses_create_stub(self): - os.makedirs("databases/migrations") + def tearDown(self): + super().tearDown() + if DB_PATH.exists(): + DB_PATH.unlink() + + async def _reset_db(self): + db = self.app.make("db") + await db.clear() + schema = db.get_schema_builder() + for table in await schema.get_all_tables(): + if table.startswith("sqlite_"): + continue + await schema.drop_table_if_exists(table) + + def _generate(self, args, directory="databases/migrations"): + os.makedirs(directory, exist_ok=True) tester = self._tester(MakeMigrationCommand) - tester.execute("add_users --create users") + tester.execute(args) + return self._generated_file(directory), tester.io.fetch_output() + + @staticmethod + def _load_migration_class(file_path): + spec = importlib.util.spec_from_file_location( + f"generated_migration_{os.path.basename(file_path).replace('.py', '')}", + file_path, + ) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + for obj in vars(module).values(): + if isinstance(obj, type) and issubclass(obj, Migration) and obj is not Migration: + return obj + raise AssertionError(f"no Migration subclass found in {file_path}") + + def _run_generated_migration(self, file_path): + asyncio.run(self._execute_up(file_path)) + + async def _execute_up(self, file_path): + migration_class = self._load_migration_class(file_path) + db = self.app.make("db") + await db.clear() + schema = db.get_schema_builder() + await migration_class(connection="sqlite", schema=schema).up() + + async def _create_table(self, name): + db = self.app.make("db") + await db.clear() + schema = db.get_schema_builder() + async with await schema.create(name) as table: + table.increments("id") + + def _has_table(self, table): + return asyncio.run(self._async_has_table(table)) + + async def _async_has_table(self, table): + db = self.app.make("db") + await db.clear() + return await db.get_schema_builder().has_table(table) + + def _has_column(self, table, column): + return asyncio.run(self._async_has_column(table, column)) + + async def _async_has_column(self, table, column): + db = self.app.make("db") + await db.clear() + return await db.get_schema_builder().has_column(table, column) + + # -- behavior is proven by the real sqlite schema the generated migration + # produces when executed, not by the generated file's text -- + + def test_inferred_table_migration_creates_real_table(self): + file_path, output = self._generate("create_posts_table") + self.assertIn("Migration file created:", output) - _, content = self._read_single_file("databases/migrations") - self.assertIn('self.schema.create("users")', content) + self.assertFalse(self._has_table("posts")) + self._run_generated_migration(file_path) - def test_table_option_uses_table_stub(self): - os.makedirs("databases/migrations") - tester = self._tester(MakeMigrationCommand) - tester.execute("modify_users --table users") + self.assertTrue(self._has_table("posts")) + self.assertTrue(self._has_column("posts", "id")) + self.assertTrue(self._has_column("posts", "created_at")) + self.assertTrue(self._has_column("posts", "updated_at")) - _, content = self._read_single_file("databases/migrations") - self.assertIn('self.schema.table("users")', content) - self.assertNotIn("self.schema.create", content) + def test_create_option_creates_named_table(self): + file_path, _ = self._generate("add_users --create users") - def test_custom_directory_option(self): - os.makedirs("custom/migrations") - tester = self._tester(MakeMigrationCommand) - tester.execute("create_posts_table --directory custom/migrations") + self._run_generated_migration(file_path) - output = tester.io.fetch_output() + self.assertTrue(self._has_table("users")) + self.assertTrue(self._has_column("users", "id")) + + def test_table_option_migration_executes_against_existing_table(self): + # The table (alter) stub has an empty ``up`` body, so there is no new + # column to assert -- the meaningful real-DB check is that the generated + # alter migration executes cleanly against an existing table. + asyncio.run(self._create_table("widgets")) + file_path, _ = self._generate("modify_widgets --table widgets") + + self._run_generated_migration(file_path) + + self.assertTrue(self._has_table("widgets")) + + def test_custom_directory_migration_runs_from_custom_location(self): + file_path, output = self._generate( + "create_posts_table --directory custom/migrations", + directory="custom/migrations", + ) self.assertIn("custom/migrations", output) - self.assertTrue(any(f.endswith(".py") for f in os.listdir("custom/migrations"))) + + self._run_generated_migration(file_path) + + self.assertTrue(self._has_table("posts")) class TestMakeModelCommand(_TempCwdTestCase): + # make:model emits a plain Python class with no schema side effects, so + # there is nothing to run against a database -- the generated file content + # is the only meaningful contract to assert. def test_creates_model_file(self): os.makedirs("app") tester = self._tester(MakeModelCommand) @@ -115,6 +212,10 @@ def test_custom_directory_option(self): class TestMakeSeedCommand(_TempCwdTestCase): + # make:seed emits a Seeder subclass with an empty ``run`` body; it performs + # no database work on its own, so the generated file content is the only + # meaningful contract (seeders executing against a real DB are covered in + # test_db_seed_command.py). def test_creates_seed_file(self): os.makedirs("databases/seeders") tester = self._tester(MakeSeedCommand) @@ -137,6 +238,8 @@ def test_reports_when_seed_already_exists(self): class TestMakeObserverCommand(_TempCwdTestCase): + # make:observer emits a plain observer class with no schema side effects, so + # the generated file content is the only meaningful contract to assert. def test_creates_observer_file(self): tester = self._tester(MakeObserverCommand) tester.execute("Post")