Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .Jules/test_coverage_fix.md
Original file line number Diff line number Diff line change
@@ -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`.
199 changes: 199 additions & 0 deletions backend/tests/test_db.py
Original file line number Diff line number Diff line change
@@ -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

Comment on lines +124 to +129
@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

Comment on lines +130 to +141
@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"

Comment on lines +143 to +149
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
Comment on lines +186 to +199
55 changes: 54 additions & 1 deletion frontend/src/components/modals/GroupModal.test.tsx
Original file line number Diff line number Diff line change
@@ -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';

Comment on lines 2 to 5
import { GroupModal } from './GroupModal';

describe('GroupModal', () => {
afterEach(cleanup);

it('exposes truncated assignment table names accessibly', () => {
const tableName = 'analytics.extremely_long_customer_activity_table';

Expand Down Expand Up @@ -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(
<GroupModal
isOpen
businessGroups={[]}
newGroupName=" "
setNewGroupName={vi.fn()}
newGroupColor="#1f77b4"
setNewGroupColor={vi.fn()}
nodes={[]}
onCloseGroupManager={vi.fn()}
onCreateBusinessGroup={onCreateBusinessGroup}
onDeleteBusinessGroup={vi.fn()}
onAssignBusinessGroup={vi.fn()}
/>
);

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(
<GroupModal
isOpen
businessGroups={[]}
newGroupName="New Group"
setNewGroupName={vi.fn()}
newGroupColor="#1f77b4"
setNewGroupColor={vi.fn()}
nodes={[]}
onCloseGroupManager={vi.fn()}
onCreateBusinessGroup={onCreateBusinessGroup}
onDeleteBusinessGroup={vi.fn()}
onAssignBusinessGroup={vi.fn()}
/>
);

const form = screen.getByRole('dialog').querySelector('form');
expect(form).not.toBeNull();
fireEvent.submit(form!);
expect(onCreateBusinessGroup).toHaveBeenCalled();
});
});
Loading