Backend tests run on SQLite by default (78 passed, 1 skipped); no Postgres needed

- 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
This commit is contained in:
avi 2026-09-14 20:59:49 -05:00
commit a94609c07b
3 changed files with 35 additions and 9 deletions

View file

@ -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'"),
),
)

View file

@ -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"

View file

@ -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