From 6401f282551a1b32ca90cc35469af3bb3b960df6 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Thu, 22 Jan 2026 08:13:43 -0600 Subject: [PATCH] feat: add get_by_guild_id helper to config services Resolves GH #88 - Add helper methods to reduce duplication and allow usage outside of routes. - Add get_by_guild_id() to GitHubConfigService, SOTagsConfigService, AllowedUsersConfigService, and ForumConfigService - Update controller endpoints to use the new helpers - Add 8 tests for the new methods (success and not found cases) Co-Authored-By: Claude Opus 4.5 --- .../src/byte_api/domain/guilds/controllers.py | 10 +- .../src/byte_api/domain/guilds/services.py | 56 ++++++ tests/unit/api/test_guilds_services.py | 163 ++++++++++++++++++ 3 files changed, 223 insertions(+), 6 deletions(-) diff --git a/services/api/src/byte_api/domain/guilds/controllers.py b/services/api/src/byte_api/domain/guilds/controllers.py index 00cf6f55..eedb0551 100644 --- a/services/api/src/byte_api/domain/guilds/controllers.py +++ b/services/api/src/byte_api/domain/guilds/controllers.py @@ -212,8 +212,6 @@ async def get_guild_github_config( ) -> GitHubConfigSchema | OffsetPagination[GitHubConfigSchema]: """Get a guild's GitHub config by ID. - TODO(#88): a helper method that we can use outside of routes would be nice. - Args: github_service (GitHubConfigService): GitHub config service guild_id (int): Guild ID @@ -221,7 +219,7 @@ async def get_guild_github_config( Returns: GitHubConfig: GitHub config object """ - result = await github_service.get(guild_id, id_attribute="guild_id") + result = await github_service.get_by_guild_id(guild_id) return github_service.to_schema(schema_type=GitHubConfigSchema, data=result) @get( @@ -247,7 +245,7 @@ async def get_guild_sotags_config( Returns: SOTagsConfig: StackOverflow tags config object """ - result = await sotags_service.get(guild_id, id_attribute="guild_id") + result = await sotags_service.get_by_guild_id(guild_id) return sotags_service.to_schema(schema_type=SOTagsConfigSchema, data=result) @get( @@ -273,7 +271,7 @@ async def get_guild_allowed_users_config( Returns: AllowedUsersConfig: Allowed users config object """ - result = await allowed_users_service.get(guild_id, id_attribute="guild_id") + result = await allowed_users_service.get_by_guild_id(guild_id) return allowed_users_service.to_schema(schema_type=AllowedUsersConfigSchema, data=result) @get( @@ -299,5 +297,5 @@ async def get_guild_forum_config( Returns: ForumConfig: Forum config object """ - result = await forum_service.get(guild_id, id_attribute="guild_id") + result = await forum_service.get_by_guild_id(guild_id) return forum_service.to_schema(schema_type=ForumConfigSchema, data=result) diff --git a/services/api/src/byte_api/domain/guilds/services.py b/services/api/src/byte_api/domain/guilds/services.py index 723702d4..3b0e3419 100644 --- a/services/api/src/byte_api/domain/guilds/services.py +++ b/services/api/src/byte_api/domain/guilds/services.py @@ -61,6 +61,20 @@ class GitHubConfigService(SQLAlchemyAsyncRepositoryService[GitHubConfig]): repository_type = GitHubConfigRepository match_fields = ["guild_id"] + async def get_by_guild_id(self, guild_id: int) -> GitHubConfig: + """Get GitHub config by guild ID. + + Args: + guild_id: The Discord guild ID. + + Returns: + The GitHub config for the guild. + + Raises: + NotFoundError: If no config exists for the guild. + """ + return await self.get(guild_id, id_attribute="guild_id") + async def to_model(self, data: ModelDictT[GitHubConfig], operation: str | None = None) -> GitHubConfig: """Convert data to a model. @@ -86,6 +100,20 @@ class SOTagsConfigService(SQLAlchemyAsyncRepositoryService[SOTagsConfig]): repository_type = SOTagsConfigRepository match_fields = ["guild_id"] + async def get_by_guild_id(self, guild_id: int) -> SOTagsConfig: + """Get StackOverflow tags config by guild ID. + + Args: + guild_id: The Discord guild ID. + + Returns: + The StackOverflow tags config for the guild. + + Raises: + NotFoundError: If no config exists for the guild. + """ + return await self.get(guild_id, id_attribute="guild_id") + async def to_model(self, data: ModelDictT[SOTagsConfig], operation: str | None = None) -> SOTagsConfig: """Convert data to a model. @@ -111,6 +139,20 @@ class AllowedUsersConfigService(SQLAlchemyAsyncRepositoryService[AllowedUsersCon repository_type = AllowedUsersConfigRepository match_fields = ["guild_id"] + async def get_by_guild_id(self, guild_id: int) -> AllowedUsersConfig: + """Get allowed users config by guild ID. + + Args: + guild_id: The Discord guild ID. + + Returns: + The allowed users config for the guild. + + Raises: + NotFoundError: If no config exists for the guild. + """ + return await self.get(guild_id, id_attribute="guild_id") + async def to_model(self, data: ModelDictT[AllowedUsersConfig], operation: str | None = None) -> AllowedUsersConfig: """Convert data to a model. @@ -136,6 +178,20 @@ class ForumConfigService(SQLAlchemyAsyncRepositoryService[ForumConfig]): repository_type = ForumConfigRepository match_fields = ["guild_id"] + async def get_by_guild_id(self, guild_id: int) -> ForumConfig: + """Get forum config by guild ID. + + Args: + guild_id: The Discord guild ID. + + Returns: + The forum config for the guild. + + Raises: + NotFoundError: If no config exists for the guild. + """ + return await self.get(guild_id, id_attribute="guild_id") + async def to_model(self, data: ModelDictT[ForumConfig], operation: str | None = None) -> ForumConfig: """Convert data to a model. diff --git a/tests/unit/api/test_guilds_services.py b/tests/unit/api/test_guilds_services.py index e1e2fc1d..ab91273f 100644 --- a/tests/unit/api/test_guilds_services.py +++ b/tests/unit/api/test_guilds_services.py @@ -25,11 +25,17 @@ from sqlalchemy.ext.asyncio import AsyncSession __all__ = [ + "test_allowed_users_config_get_by_guild_id_not_found", + "test_allowed_users_config_get_by_guild_id_success", "test_allowed_users_config_repository_create", "test_allowed_users_config_service_operations", + "test_forum_config_get_by_guild_id_not_found", + "test_forum_config_get_by_guild_id_success", "test_forum_config_repository_type", "test_forum_config_service_bug_fix_verification", "test_forum_config_service_operations", + "test_github_config_get_by_guild_id_not_found", + "test_github_config_get_by_guild_id_success", "test_github_config_repository_create", "test_github_config_service_match_fields", "test_guilds_repository_create", @@ -39,6 +45,8 @@ "test_guilds_repository_update", "test_guilds_service_create", "test_guilds_service_match_fields", + "test_sotags_config_get_by_guild_id_not_found", + "test_sotags_config_get_by_guild_id_success", "test_sotags_config_repository_create", "test_sotags_config_service_operations", ] @@ -452,3 +460,158 @@ async def test_forum_config_service_bug_fix_verification(db_session: AsyncSessio configs, count = await service.list_and_count() assert count >= 1 assert any(c.id == created_config.id for c in configs) + + +# --- get_by_guild_id Helper Method Tests --- + + +@pytest.mark.asyncio +@pytest.mark.unit +async def test_github_config_get_by_guild_id_success(db_session: AsyncSession, sample_guild: Guild) -> None: + """Test GitHubConfigService.get_by_guild_id returns config for valid guild.""" + guild_repo = GuildsRepository(session=db_session) + await guild_repo.add(sample_guild) + await db_session.flush() + + service = GitHubConfigService(session=db_session) + created_config = await service.create( + { + "guild_id": sample_guild.guild_id, + "github_organization": "test-org", + "github_repository": "test-repo", + "discussion_sync": True, + } + ) + + result = await service.get_by_guild_id(sample_guild.guild_id) + + assert result.id == created_config.id + assert result.guild_id == sample_guild.guild_id + assert result.github_organization == "test-org" + + +@pytest.mark.asyncio +@pytest.mark.unit +async def test_github_config_get_by_guild_id_not_found(db_session: AsyncSession) -> None: + """Test GitHubConfigService.get_by_guild_id raises NotFoundError for missing config.""" + from advanced_alchemy.exceptions import NotFoundError + + service = GitHubConfigService(session=db_session) + + with pytest.raises(NotFoundError): + await service.get_by_guild_id(999999999) + + +@pytest.mark.asyncio +@pytest.mark.unit +async def test_sotags_config_get_by_guild_id_success(db_session: AsyncSession, sample_guild: Guild) -> None: + """Test SOTagsConfigService.get_by_guild_id returns config for valid guild.""" + guild_repo = GuildsRepository(session=db_session) + await guild_repo.add(sample_guild) + await db_session.flush() + + service = SOTagsConfigService(session=db_session) + created_config = await service.create( + { + "guild_id": sample_guild.guild_id, + "tag_name": "python", + } + ) + + result = await service.get_by_guild_id(sample_guild.guild_id) + + assert result.id == created_config.id + assert result.guild_id == sample_guild.guild_id + assert result.tag_name == "python" + + +@pytest.mark.asyncio +@pytest.mark.unit +async def test_sotags_config_get_by_guild_id_not_found(db_session: AsyncSession) -> None: + """Test SOTagsConfigService.get_by_guild_id raises NotFoundError for missing config.""" + from advanced_alchemy.exceptions import NotFoundError + + service = SOTagsConfigService(session=db_session) + + with pytest.raises(NotFoundError): + await service.get_by_guild_id(999999999) + + +@pytest.mark.asyncio +@pytest.mark.unit +async def test_allowed_users_config_get_by_guild_id_success( + db_session: AsyncSession, + sample_guild: Guild, + sample_user: User, +) -> None: + """Test AllowedUsersConfigService.get_by_guild_id returns config for valid guild.""" + guild_repo = GuildsRepository(session=db_session) + await guild_repo.add(sample_guild) + await db_session.flush() + + await db_session.merge(sample_user) + await db_session.flush() + + service = AllowedUsersConfigService(session=db_session) + created_config = await service.create( + { + "guild_id": sample_guild.guild_id, + "user_id": sample_user.id, + } + ) + + result = await service.get_by_guild_id(sample_guild.guild_id) + + assert result.id == created_config.id + assert result.guild_id == sample_guild.guild_id + + +@pytest.mark.asyncio +@pytest.mark.unit +async def test_allowed_users_config_get_by_guild_id_not_found(db_session: AsyncSession) -> None: + """Test AllowedUsersConfigService.get_by_guild_id raises NotFoundError for missing config.""" + from advanced_alchemy.exceptions import NotFoundError + + service = AllowedUsersConfigService(session=db_session) + + with pytest.raises(NotFoundError): + await service.get_by_guild_id(999999999) + + +@pytest.mark.asyncio +@pytest.mark.unit +async def test_forum_config_get_by_guild_id_success(db_session: AsyncSession, sample_guild: Guild) -> None: + """Test ForumConfigService.get_by_guild_id returns config for valid guild.""" + guild_repo = GuildsRepository(session=db_session) + await guild_repo.add(sample_guild) + await db_session.flush() + + service = ForumConfigService(session=db_session) + created_config = await service.create( + { + "guild_id": sample_guild.guild_id, + "help_forum": True, + "help_forum_category": "Help", + "help_thread_auto_close": False, + "showcase_forum": False, + } + ) + + result = await service.get_by_guild_id(sample_guild.guild_id) + + assert result.id == created_config.id + assert result.guild_id == sample_guild.guild_id + assert result.help_forum is True + assert result.help_forum_category == "Help" + + +@pytest.mark.asyncio +@pytest.mark.unit +async def test_forum_config_get_by_guild_id_not_found(db_session: AsyncSession) -> None: + """Test ForumConfigService.get_by_guild_id raises NotFoundError for missing config.""" + from advanced_alchemy.exceptions import NotFoundError + + service = ForumConfigService(session=db_session) + + with pytest.raises(NotFoundError): + await service.get_by_guild_id(999999999)