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):