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:
parent
adc40201a1
commit
a94609c07b
3 changed files with 35 additions and 9 deletions
|
|
@ -26,6 +26,7 @@ from sqlalchemy import (
|
||||||
String,
|
String,
|
||||||
Text,
|
Text,
|
||||||
UniqueConstraint,
|
UniqueConstraint,
|
||||||
|
text,
|
||||||
)
|
)
|
||||||
from sqlalchemy.dialects.postgresql import JSONB
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
@ -170,6 +171,7 @@ class Recording(Base, PublicIdMixin):
|
||||||
"client_recording_id",
|
"client_recording_id",
|
||||||
unique=True,
|
unique=True,
|
||||||
postgresql_where="client_recording_id IS NOT NULL",
|
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__ = (
|
__table_args__ = (
|
||||||
# At most one immutable "original" per recording.
|
# 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(
|
Index(
|
||||||
"uq_assets_one_original",
|
"uq_assets_one_original",
|
||||||
"recording_id",
|
"recording_id",
|
||||||
unique=True,
|
unique=True,
|
||||||
postgresql_where="kind = 'original'",
|
postgresql_where="kind = 'original'",
|
||||||
|
sqlite_where=text("kind = 'original'"),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,10 @@
|
||||||
"""Pytest fixtures.
|
"""Pytest fixtures.
|
||||||
|
|
||||||
Tests run against a real PostgreSQL (deploy/docker-compose.dev.yml) using a
|
Default: SQLite (aiosqlite) in a temp dir — matches the desktop
|
||||||
dedicated ``shonar_test`` database, plus a temporary local storage root.
|
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
|
from __future__ import annotations
|
||||||
|
|
@ -15,9 +18,11 @@ import pytest
|
||||||
import pytest_asyncio
|
import pytest_asyncio
|
||||||
|
|
||||||
# Configure env BEFORE importing the app so Settings picks it up.
|
# Configure env BEFORE importing the app so Settings picks it up.
|
||||||
|
_db_dir = tempfile.mkdtemp(prefix="shonar-test-db-")
|
||||||
TEST_DB = os.environ.get(
|
TEST_DB = os.environ.get(
|
||||||
"SHONAR_TEST_DATABASE_URL",
|
"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_DATABASE_URL"] = TEST_DB
|
||||||
os.environ["SHONAR_SECRET_KEY"] = "test-secret-key-0123456789abcdef0123456789abcdef"
|
os.environ["SHONAR_SECRET_KEY"] = "test-secret-key-0123456789abcdef0123456789abcdef"
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ via SHONAR_TEST_DATABASE_URL) — both paths share the endpoint contract.
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import io
|
import io
|
||||||
|
import uuid
|
||||||
import zipfile
|
import zipfile
|
||||||
|
|
||||||
from tests.test_recordings import auth, user_tokens, wav_bytes
|
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
|
from shonar.db.session import session_factory
|
||||||
|
|
||||||
async with session_factory()() as s:
|
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()
|
await s.commit()
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -53,7 +62,13 @@ async def add_summary(rec_id: str, content: dict):
|
||||||
from shonar.db.session import session_factory
|
from shonar.db.session import session_factory
|
||||||
|
|
||||||
async with session_factory()() as s:
|
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()
|
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)
|
r = await client.delete(f"/api/v1/recordings/{rid}", headers=h)
|
||||||
assert r.status_code == 204
|
assert r.status_code == 204
|
||||||
async with session_factory()() as s:
|
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)
|
rec.deleted_at = utcnow() - timedelta(days=31)
|
||||||
# remember the storage key before the row vanishes
|
# remember the storage key before the row vanishes
|
||||||
from shonar.db.models import Asset
|
from shonar.db.models import Asset
|
||||||
|
|
@ -260,7 +275,7 @@ async def test_retention_sweep_purges_expired(client):
|
||||||
assert purged["files"] >= 1
|
assert purged["files"] >= 1
|
||||||
|
|
||||||
async with session_factory()() as s:
|
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
|
from shonar.storage import get_storage
|
||||||
|
|
||||||
assert not await get_storage().exists(storage_key) # file gone too
|
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()
|
purged = await retention.sweep_deleted()
|
||||||
assert purged["recordings"] == 0
|
assert purged["recordings"] == 0
|
||||||
async with session_factory()() as s:
|
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):
|
async def test_retention_sweep_account(client):
|
||||||
|
|
@ -292,7 +307,7 @@ async def test_retention_sweep_account(client):
|
||||||
purged = await retention.sweep_deleted()
|
purged = await retention.sweep_deleted()
|
||||||
assert purged["users"] == 1
|
assert purged["users"] == 1
|
||||||
async with session_factory()() as s:
|
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
|
assert await s.scalar(select_user("m9r2@example.com")) is None
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue