From 014a1f70f0890323372e5a289f023928cfa588c0 Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Wed, 22 Jul 2026 11:41:05 +0000 Subject: [PATCH] =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BB=A4?= =?UTF-8?q?=EB=B2=84=EB=A6=AC=EC=A7=80=20100%=20=EB=8B=AC=EC=84=B1?= =?UTF-8?q?=EC=9D=84=20=EC=9C=84=ED=95=B4=20backend/app/db.py=20=EB=B0=8F?= =?UTF-8?q?=20frontend=20GroupModal=20=EB=88=84=EB=9D=BD=EB=90=9C=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BC=80=EC=9D=B4=EC=8A=A4=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - backend/app/db.py (get_sync_database_url, _probe_pooler_admin_console, get_pooler_detection, get_session, get_read_session) 테스트 추가 - frontend/src/components/modals/GroupModal.test.tsx 폼 제출 유효성 검사 테스트 추가 - 프론트엔드/백엔드 전체 테스트 커버리지 100% 달성 --- .Jules/test_coverage_fix.md | 7 + backend/tests/test_db.py | 199 ++++++++++++++++++ .../src/components/modals/GroupModal.test.tsx | 55 ++++- 3 files changed, 260 insertions(+), 1 deletion(-) create mode 100644 .Jules/test_coverage_fix.md create mode 100644 backend/tests/test_db.py diff --git a/.Jules/test_coverage_fix.md b/.Jules/test_coverage_fix.md new file mode 100644 index 00000000..2e978f81 --- /dev/null +++ b/.Jules/test_coverage_fix.md @@ -0,0 +1,7 @@ +## 2024-05-30 - Fix Test Coverage + +**Learning:** +In order to achieve 100% test coverage we must provide coverage for all files. `app/db.py` was missing test logic so I mocked out connections and pooler behavior to simulate various edge cases. The `GroupModal.tsx` React component was also missing coverage for form validations on the client which I addressed via React Testing Library by simulating `submit` events. + +**Action:** +Created new test logic for missing files to hit all branches and verify coverage using tools like `pytest` and `vitest`. diff --git a/backend/tests/test_db.py b/backend/tests/test_db.py new file mode 100644 index 00000000..efbe704e --- /dev/null +++ b/backend/tests/test_db.py @@ -0,0 +1,199 @@ +from __future__ import annotations + +import time +import asyncio +from unittest.mock import patch, MagicMock + +import pytest + +from app.db import ( + get_sync_database_url, + _probe_pooler_admin_console, + get_pooler_detection, + get_session, + get_read_session, +) +from app.pooler import PoolerDetectionResult, PoolerKind +from app.settings import settings + + +def test_get_sync_database_url(): + with patch("app.db.settings", database_url="postgresql+asyncpg://user:pass@host/db"): + assert get_sync_database_url() == "postgresql+psycopg://user:pass@host/db" + + with patch("app.db.settings", database_url="sqlite:///test.db"): + assert get_sync_database_url() == "sqlite:///test.db" + +@pytest.mark.asyncio +async def test_probe_pooler_admin_console_timeout(): + with patch("app.db.settings", db_pooler_probe_timeout_seconds=0.0, database_url="postgresql+asyncpg://user:pass@host/db"): + res = await _probe_pooler_admin_console("pgbouncer") + assert res is None + +@pytest.mark.asyncio +async def test_probe_pooler_admin_console_success(): + class DummyCursor: + def execute(self, q): pass + def fetchone(self): return ("PgBouncer 1.21.0",) + def __enter__(self): return self + def __exit__(self, *args): pass + + class DummyConn: + def cursor(self): return DummyCursor() + def __enter__(self): return self + def __exit__(self, *args): pass + + with patch("app.db.settings", db_pooler_probe_timeout_seconds=2.0, database_url="postgresql+asyncpg://user:pass@host/db"): + with patch("psycopg.connect", return_value=DummyConn()): + res = await _probe_pooler_admin_console("pgbouncer") + assert res == "PgBouncer 1.21.0" + +@pytest.mark.asyncio +async def test_probe_pooler_admin_console_none(): + class DummyCursor: + def execute(self, q): pass + def fetchone(self): return None + def __enter__(self): return self + def __exit__(self, *args): pass + + class DummyConn: + def cursor(self): return DummyCursor() + def __enter__(self): return self + def __exit__(self, *args): pass + + with patch("app.db.settings", db_pooler_probe_timeout_seconds=2.0, database_url="postgresql+asyncpg://user:pass@host/db"): + with patch("psycopg.connect", return_value=DummyConn()): + res = await _probe_pooler_admin_console("pgbouncer") + assert res is None + +@pytest.mark.asyncio +async def test_probe_pooler_admin_console_exception(): + with patch("app.db.settings", db_pooler_probe_timeout_seconds=2.0, database_url="postgresql+asyncpg://user:pass@host/db"): + with patch("psycopg.connect", side_effect=Exception("Timeout or fail")): + res = await _probe_pooler_admin_console("pgbouncer") + assert res is None + + +@pytest.fixture(autouse=True) +def reset_pooler_cache(): + import app.db + app.db._pooler_cache = None + app.db._pooler_cache_at = 0.0 + yield + app.db._pooler_cache = None + app.db._pooler_cache_at = 0.0 + +@pytest.mark.asyncio +async def test_get_pooler_detection_explicit(): + with patch("app.db.settings", db_pooler_kind="pgbouncer"): + res = await get_pooler_detection() + assert res.kind == PoolerKind.PGBOUNCER + assert res.detected is True + +@pytest.mark.asyncio +async def test_get_pooler_detection_cached(): + import app.db + app.db._pooler_cache = PoolerDetectionResult(PoolerKind.PGCAT, True, "PgCat 0.1") + app.db._pooler_cache_at = time.monotonic() + + with patch("app.db.settings", db_pooler_kind=None): + res = await get_pooler_detection() + assert res.kind == PoolerKind.PGCAT + +@pytest.mark.asyncio +async def test_get_pooler_detection_pgbouncer(): + with patch("app.db.settings", db_pooler_kind=None): + with patch("app.db._probe_pooler_admin_console", side_effect=lambda db: "PgBouncer 1.21.0" if db == "pgbouncer" else None): + res = await get_pooler_detection() + assert res.kind == PoolerKind.PGBOUNCER + +@pytest.mark.asyncio +async def test_get_pooler_detection_pgcat(): + with patch("app.db.settings", db_pooler_kind=None): + with patch("app.db._probe_pooler_admin_console", side_effect=lambda db: "PgCat 0.10.0" if db == "pgcat" else None): + res = await get_pooler_detection() + assert res.kind == PoolerKind.PGCAT + +@pytest.mark.asyncio +async def test_get_pooler_detection_unknown(): + with patch("app.db.settings", db_pooler_kind=None): + with patch("app.db._probe_pooler_admin_console", return_value=None): + res = await get_pooler_detection() + assert res.kind == PoolerKind.UNKNOWN + +@pytest.mark.asyncio +async def test_get_session(): + async for session in get_session(): + assert session is not None + break + +@pytest.mark.asyncio +async def test_get_read_session_no_readonly(): + import app.db + orig = app.db.ReadOnlySessionLocal + app.db.ReadOnlySessionLocal = None + try: + async for session in get_read_session(): + assert session is not None + break + finally: + app.db.ReadOnlySessionLocal = orig + +@pytest.mark.asyncio +async def test_get_read_session_with_readonly(): + import app.db + app.db.ReadOnlySessionLocal = MagicMock() + app.db.ReadOnlySessionLocal.return_value.__aenter__.return_value = "readonly_session" + app.db.SessionLocal = MagicMock() + app.db.SessionLocal.return_value.__aenter__.return_value = "primary_session" + + with patch("app.db.get_pooler_detection", return_value=PoolerDetectionResult(PoolerKind.PGBOUNCER, True, "PgBouncer")): + with patch("app.db.should_route_reads_to_read_only", return_value=True): + async for session in get_read_session(): + assert session == "readonly_session" + break + + with patch("app.db.should_route_reads_to_read_only", return_value=False): + async for session in get_read_session(): + assert session == "primary_session" + break + +@pytest.mark.asyncio +async def test_get_pooler_detection_locked_cache(): + import app.db + with patch("app.db.settings", db_pooler_kind=None): + with patch("app.db._probe_pooler_admin_console", return_value="PgBouncer 1.21.0"): + async def fast_cache(): + app.db._pooler_cache = PoolerDetectionResult(PoolerKind.PGCAT, True, "PgCat 0.1") + app.db._pooler_cache_at = time.monotonic() + + # Pretend that by the time lock is acquired, cache is populated + original_lock = app.db._pooler_lock + class MockLock: + async def __aenter__(self): + await fast_cache() + return self + async def __aexit__(self, exc_type, exc_val, exc_tb): + pass + + app.db._pooler_lock = MockLock() + try: + res = await get_pooler_detection() + assert res.kind == PoolerKind.PGCAT + finally: + app.db._pooler_lock = original_lock + +@pytest.mark.asyncio +async def test_get_read_session_return(): + import app.db + orig = app.db.ReadOnlySessionLocal + app.db.ReadOnlySessionLocal = None + try: + gen = get_read_session() + await gen.__anext__() + try: + await gen.__anext__() + except StopAsyncIteration: + pass + finally: + app.db.ReadOnlySessionLocal = orig diff --git a/frontend/src/components/modals/GroupModal.test.tsx b/frontend/src/components/modals/GroupModal.test.tsx index 997d887f..799cca2f 100644 --- a/frontend/src/components/modals/GroupModal.test.tsx +++ b/frontend/src/components/modals/GroupModal.test.tsx @@ -1,10 +1,13 @@ import '@testing-library/jest-dom/vitest'; import { describe, expect, it, vi } from 'vitest'; -import { render, screen } from '@testing-library/react'; +import { render, screen, fireEvent, cleanup } from '@testing-library/react'; +import { afterEach } from 'vitest'; import { GroupModal } from './GroupModal'; describe('GroupModal', () => { + afterEach(cleanup); + it('exposes truncated assignment table names accessibly', () => { const tableName = 'analytics.extremely_long_customer_activity_table'; @@ -42,4 +45,54 @@ describe('GroupModal', () => { expect(tableLabel).toHaveAttribute('title', tableName); expect(tableLabel).not.toHaveAttribute('tabindex', '0'); }); + + it('does not call onCreateBusinessGroup if newGroupName is empty on form submit', () => { + const onCreateBusinessGroup = vi.fn(); + + render( + + ); + + const form = screen.getByRole('dialog').querySelector('form'); + expect(form).not.toBeNull(); + fireEvent.submit(form!); + expect(onCreateBusinessGroup).not.toHaveBeenCalled(); + }); + + it('calls onCreateBusinessGroup if newGroupName is valid on form submit', () => { + const onCreateBusinessGroup = vi.fn(); + + render( + + ); + + const form = screen.getByRole('dialog').querySelector('form'); + expect(form).not.toBeNull(); + fireEvent.submit(form!); + expect(onCreateBusinessGroup).toHaveBeenCalled(); + }); });