From a94609c07b5175d598e4233c9fb62bc18f43e3b3 Mon Sep 17 00:00:00 2001 From: avi Date: Mon, 14 Sep 2026 20:59:49 -0500 Subject: [PATCH] Backend tests run on SQLite by default (78 passed, 1 skipped); no Postgres needed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - conftest: default SHONAR_TEST_DATABASE_URL is a temp SQLite file, matching the bundled-lite engine; set the env var to a PG URL to exercise that path - models.py: add sqlite_where to the two partial unique indexes — without it SQLite built a FULL unique index on recording_id (WHERE not carried over), wrongly blocking a second export asset per recording - test_m9: pass UUID objects (not str) to direct ORM inserts/gets; SQLite's GUID bind processor rejects strings (asyncpg tolerated them) Verified: pytest -q = 78 passed, 1 skipped; ruff check clean --- backend/shonar/db/models.py | 6 ++++++ backend/tests/conftest.py | 11 ++++++++--- backend/tests/test_m9.py | 27 +++++++++++++++++++++------ 3 files changed, 35 insertions(+), 9 deletions(-) diff --git a/backend/shonar/db/models.py b/backend/shonar/db/models.py index a19f4c2..b5e0bef 100644 --- a/backend/shonar/db/models.py +++ b/backend/shonar/db/models.py @@ -26,6 +26,7 @@ from sqlalchemy import ( String, Text, UniqueConstraint, + text, ) from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.orm import Mapped, mapped_column, relationship @@ -170,6 +171,7 @@ class Recording(Base, PublicIdMixin): "client_recording_id", unique=True, postgresql_where="client_recording_id IS NOT NULL", + sqlite_where=text("client_recording_id IS NOT NULL"), ), ) @@ -204,11 +206,15 @@ class Asset(Base, PublicIdMixin): __table_args__ = ( # At most one immutable "original" per recording. + # sqlite_where is required as well as postgresql_where: without it + # SQLite gets a FULL unique index on recording_id (the WHERE clause + # is not carried over), which wrongly blocks >1 export asset/recording. Index( "uq_assets_one_original", "recording_id", unique=True, postgresql_where="kind = 'original'", + sqlite_where=text("kind = 'original'"), ), ) diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index a19a81e..d9c69bf 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -1,7 +1,10 @@ """Pytest fixtures. -Tests run against a real PostgreSQL (deploy/docker-compose.dev.yml) using a -dedicated ``shonar_test`` database, plus a temporary local storage root. +Default: SQLite (aiosqlite) in a temp dir — matches the desktop +bundled-lite engine and needs no services running. Set +``SHONAR_TEST_DATABASE_URL`` to a PostgreSQL URL (e.g. +``postgresql+asyncpg://shonar:shonar@localhost:5432/shonar_test``) to +exercise the Postgres path, including the tsvector FTS columns. """ from __future__ import annotations @@ -15,9 +18,11 @@ import pytest import pytest_asyncio # Configure env BEFORE importing the app so Settings picks it up. +_db_dir = tempfile.mkdtemp(prefix="shonar-test-db-") TEST_DB = os.environ.get( "SHONAR_TEST_DATABASE_URL", - "postgresql+asyncpg://shonar:shonar@localhost:5432/shonar_test", + # Bundled-lite default: throwaway SQLite file; no services required. + f"sqlite+aiosqlite:///{os.path.join(_db_dir, 'test.db')}", ) os.environ["SHONAR_DATABASE_URL"] = TEST_DB os.environ["SHONAR_SECRET_KEY"] = "test-secret-key-0123456789abcdef0123456789abcdef" diff --git a/backend/tests/test_m9.py b/backend/tests/test_m9.py index 6cc5339..2a749a7 100644 --- a/backend/tests/test_m9.py +++ b/backend/tests/test_m9.py @@ -7,6 +7,7 @@ via SHONAR_TEST_DATABASE_URL) — both paths share the endpoint contract. from __future__ import annotations import io +import uuid import zipfile from tests.test_recordings import auth, user_tokens, wav_bytes @@ -44,7 +45,15 @@ async def add_transcript(rec_id: str, text: str, segments=None): from shonar.db.session import session_factory async with session_factory()() as s: - s.add(Transcript(recording_id=rec_id, text=text, segments=segments, provider="test")) + # UUID object (not str): SQLite's GUID bind processor requires it. + s.add( + Transcript( + recording_id=uuid.UUID(rec_id) if isinstance(rec_id, str) else rec_id, + text=text, + segments=segments, + provider="test", + ) + ) await s.commit() @@ -53,7 +62,13 @@ async def add_summary(rec_id: str, content: dict): from shonar.db.session import session_factory async with session_factory()() as s: - s.add(Summary(recording_id=rec_id, content=content, provider="test")) + s.add( + Summary( + recording_id=uuid.UUID(rec_id) if isinstance(rec_id, str) else rec_id, + content=content, + provider="test", + ) + ) await s.commit() @@ -241,7 +256,7 @@ async def test_retention_sweep_purges_expired(client): r = await client.delete(f"/api/v1/recordings/{rid}", headers=h) assert r.status_code == 204 async with session_factory()() as s: - rec = await s.get(Recording, rid) + rec = await s.get(Recording, uuid.UUID(rid)) rec.deleted_at = utcnow() - timedelta(days=31) # remember the storage key before the row vanishes from shonar.db.models import Asset @@ -260,7 +275,7 @@ async def test_retention_sweep_purges_expired(client): assert purged["files"] >= 1 async with session_factory()() as s: - assert await s.get(Recording, rid) is None + assert await s.get(Recording, uuid.UUID(rid)) is None from shonar.storage import get_storage assert not await get_storage().exists(storage_key) # file gone too @@ -271,7 +286,7 @@ async def test_retention_sweep_purges_expired(client): purged = await retention.sweep_deleted() assert purged["recordings"] == 0 async with session_factory()() as s: - assert await s.get(Recording, rid2) is not None + assert await s.get(Recording, uuid.UUID(rid2)) is not None async def test_retention_sweep_account(client): @@ -292,7 +307,7 @@ async def test_retention_sweep_account(client): purged = await retention.sweep_deleted() assert purged["users"] == 1 async with session_factory()() as s: - assert await s.get(Recording, rid) is None # cascade + assert await s.get(Recording, uuid.UUID(rid)) is None # cascade assert await s.scalar(select_user("m9r2@example.com")) is None