Files
VoiceStudio/backend/tests/test_gallery_profiles.py
T
Palash DebnathandClaude Fable 5 0ee62b2261 feat(gallery): save gallery voices as profiles, with validated audio references (#1542)
* feat(gallery): save gallery voices as profiles, with validated audio references

Work-in-progress lifted from the concurrent gallery session at the
owner's request (its uncommitted working tree, preserved verbatim from
base 92b1ee5d; safety snapshot remains at rescue/gallery-wip):

- gallery voices can be saved as local profiles: audio is copied into
  the profile store with content-addressed filenames, existing profiles
  are detected and refreshed only when the source clip changed
- backend/core/audio_validation.py: symlink-rejecting, root-contained
  resolution for persisted profile WAV references, with tests
- archetype/community routers and the Voice Gallery UI updated for the
  save-as-profile handoff (spec: docs/specs/longform/26-gallery-use-handoff.md)
- locale updates for the new gallery strings across all 21 files

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore: drop a stray local screenshot script that rode in with the tree copy

* fix(community): explain the tolerated Content-Length parse failure; drop an unused import

CodeQL on #1542: the empty except now says why it is safe (the streamed
byte counter enforces the same cap regardless), and the test file loses
an unused Path import.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(gallery): review findings — copy outside the write lock, no stale completions

CodeRabbit on #1542, all findings addressed:
- the profile-audio copy stages to a .part temp BEFORE BEGIN IMMEDIATE
  and publishes via atomic os.replace inside it — other backend writers
  no longer block for the duration of an audio copy; a mid-copy failure
  leaves no temp droppings and no profile row (both pinned by tests)
- VoiceGallery async ops carry per-operation generation tokens: a
  preview or save-as-profile that resolves after unmount (or after a
  newer operation) can no longer play audio, redirect into a workspace,
  or touch state — three fail-before regression tests
- VoiceGalleryActions imports the page at test runtime; the e2e locator
  uses a stable data-testid instead of a translated string; symlink
  tests skip cleanly where the OS can't create symlinks; the changelog
  line carries its PR ref

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ci: static ffmpeg fallback when the chocolatey feed is down

Third feed outage to break a PR run (2026-07-20, 2026-07-28, today —
three attempts, three 'installed 0/1'). Chocolatey is a distribution
channel, not the dependency: after the retry loop exhausts, fetch the
static gyan.dev build from its GitHub release mirror and put it on
PATH — same binary, no feed in the path. URL verified live (HTTP 200).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 22:48:05 +00:00

251 lines
8.9 KiB
Python

"""Gallery-import profile materialization contracts."""
from __future__ import annotations
import shutil
import sqlite3
import time
import uuid
from pathlib import Path
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from api.routers import gallery
from core.db import db_conn, init_db
@pytest.fixture(scope="module")
def client():
init_db()
gallery._init_gallery_db()
app = FastAPI()
app.include_router(gallery.router)
return TestClient(app)
def _gallery_voice(
suffix: str = ".wav", content: bytes = b"RIFF imported voice",
) -> tuple[str, Path]:
voice_id = f"g{uuid.uuid4().hex[:7]}"
path = gallery.VOICE_GALLERY_DIR / f"{voice_id}{suffix}"
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(content)
with db_conn() as conn:
conn.execute(
"""INSERT INTO voice_gallery
(id, name, character, category, source_type, source_url, audio_path,
duration, description, tags, created_at)
VALUES (?, ?, ?, 'import', 'youtube', ?, ?, 5.0, ?, '[]', ?)""",
(
voice_id, "Imported narrator", "Video title is not an instruct",
"https://example.invalid/source", str(path),
"Source URL/notes are not a spoken transcript", time.time(),
),
)
return voice_id, path
def test_save_as_profile_keeps_import_metadata_out_of_tts_fields(client):
voice_id, _ = _gallery_voice()
response = client.post(
f"/gallery/voices/{voice_id}/save-as-profile",
params={"profile_name": "Reusable import"},
)
assert response.status_code == 200
with db_conn() as conn:
row = conn.execute(
"SELECT * FROM voice_profiles WHERE id=?", (response.json()["profile_id"],),
).fetchone()
assert row["kind"] == "clone"
assert row["personality"] == f"gallery:{voice_id}"
assert row["ref_text"] == ""
assert row["instruct"] == ""
assert row["description"] == "Source URL/notes are not a spoken transcript"
def test_to_profile_uses_live_schema_and_clone_metadata(client):
voice_id, _ = _gallery_voice()
response = client.post(f"/gallery/voices/{voice_id}/to-profile")
assert response.status_code == 200
with db_conn() as conn:
row = conn.execute(
"SELECT * FROM voice_profiles WHERE id=?", (response.json()["profile_id"],),
).fetchone()
assert row["kind"] == "clone"
assert row["personality"] == f"gallery:{voice_id}"
assert row["ref_text"] == row["instruct"] == ""
assert row["description"] == "Source URL/notes are not a spoken transcript"
def test_both_import_routes_share_one_idempotent_profile(client, monkeypatch):
emitted = []
monkeypatch.setattr(
gallery.event_bus, "emit", lambda topic, payload: emitted.append((topic, payload)),
)
voice_id, _ = _gallery_voice()
first = client.post(
f"/gallery/voices/{voice_id}/save-as-profile",
params={"profile_name": "One reusable profile"},
)
repeated = client.post(
f"/gallery/voices/{voice_id}/save-as-profile",
params={"profile_name": "Ignored duplicate name"},
)
alternate = client.post(f"/gallery/voices/{voice_id}/to-profile")
assert first.status_code == repeated.status_code == alternate.status_code == 200
assert {
first.json()["profile_id"],
repeated.json()["profile_id"],
alternate.json()["profile_id"],
} == {first.json()["profile_id"]}
with db_conn() as conn:
rows = conn.execute(
"SELECT * FROM voice_profiles WHERE personality=?",
(f"gallery:{voice_id}",),
).fetchall()
assert len(rows) == 1
assert rows[0]["name"] == "One reusable profile"
assert rows[0]["kind"] == "clone" and rows[0]["vd_states"] is None
assert emitted[-1] == (
"profiles", {"action": "updated", "id": first.json()["profile_id"]},
)
def test_gallery_profile_repairs_a_missing_copy_without_duplication(client):
voice_id, source = _gallery_voice()
first = client.post(f"/gallery/voices/{voice_id}/to-profile")
assert first.status_code == 200
with db_conn() as conn:
row = conn.execute(
"SELECT * FROM voice_profiles WHERE id=?", (first.json()["profile_id"],),
).fetchone()
copied = Path(gallery.VOICES_DIR) / row["ref_audio_path"]
copied.unlink()
repaired = client.post(f"/gallery/voices/{voice_id}/to-profile")
assert repaired.status_code == 200
assert repaired.json()["profile_id"] == first.json()["profile_id"]
assert copied.read_bytes() == source.read_bytes()
def test_gallery_profile_does_not_rewrite_a_namespaced_import_collision(client):
voice_id, source = _gallery_voice()
collision_id = f"c{uuid.uuid4().hex[:7]}"
personality = f"gallery:{voice_id}"
collision_name = gallery._gallery_profile_audio_filename(collision_id, source)
collision_audio = Path(gallery.VOICES_DIR) / collision_name
collision_audio.parent.mkdir(parents=True, exist_ok=True)
collision_audio.write_bytes(b"user-owned audio")
with db_conn() as conn:
conn.execute(
"INSERT INTO voice_profiles "
"(id, name, ref_audio_path, ref_text, instruct, language, seed, personality, "
"description, kind, vd_states, is_locked, verified_own_voice, created_at) "
"VALUES (?, 'User profile', ?, '', '', 'Auto', NULL, ?, "
"'user-owned metadata', 'clone', NULL, 0, 0, ?)",
(collision_id, collision_name, personality, time.time()),
)
response = client.post(f"/gallery/voices/{voice_id}/to-profile")
assert response.status_code == 200
assert response.json()["profile_id"] != collision_id
with db_conn() as conn:
collision = conn.execute(
"SELECT * FROM voice_profiles WHERE id=?", (collision_id,),
).fetchone()
created = conn.execute(
"SELECT * FROM voice_profiles WHERE id=?", (response.json()["profile_id"],),
).fetchone()
assert collision["description"] == "user-owned metadata"
assert collision_audio.read_bytes() == b"user-owned audio"
assert created["personality"] == personality
def _part_files() -> set[Path]:
return set(Path(gallery.VOICES_DIR).glob("*.part")) | set(
Path(gallery.VOICES_DIR).glob(".*.part")
)
def test_audio_copy_never_holds_the_db_write_lock(client, monkeypatch):
"""The bulk file copy must happen BEFORE the BEGIN IMMEDIATE transaction.
While the copy runs, another backend writer takes (and releases) SQLite's
write lock. If materialization copied inside its own write transaction,
this concurrent writer would hit `database is locked` and the test fails.
"""
from core.config import DB_PATH
voice_id, _ = _gallery_voice()
real_copy2 = shutil.copy2
concurrent_writes = []
def copy_and_probe(src, dst, **kwargs):
probe = sqlite3.connect(DB_PATH, timeout=0.5)
try:
probe.execute("BEGIN IMMEDIATE")
probe.execute(
"UPDATE voice_gallery SET category = category WHERE id = ?",
(voice_id,),
)
probe.commit()
concurrent_writes.append(True)
finally:
probe.close()
return real_copy2(src, dst, **kwargs)
monkeypatch.setattr(gallery.shutil, "copy2", copy_and_probe)
response = client.post(f"/gallery/voices/{voice_id}/to-profile")
assert response.status_code == 200
assert concurrent_writes == [True]
assert _part_files() == set()
def test_failed_copy_leaves_no_temp_droppings_or_profile_row(client, monkeypatch):
"""A copy that dies mid-write must not leave .part files or a DB row."""
voice_id, _ = _gallery_voice()
def exploding_copy(src, dst, **kwargs):
Path(dst).write_bytes(b"partial bytes")
raise OSError("disk full mid-copy")
monkeypatch.setattr(gallery.shutil, "copy2", exploding_copy)
with pytest.raises(OSError, match="disk full mid-copy"):
client.post(f"/gallery/voices/{voice_id}/to-profile")
assert _part_files() == set()
with db_conn() as conn:
rows = conn.execute(
"SELECT * FROM voice_profiles WHERE personality = ?",
(f"gallery:{voice_id}",),
).fetchall()
assert rows == []
def test_gallery_preview_serves_outputs_file_without_root_relative_redirect(client):
voice_id, source = _gallery_voice()
response = client.get(
f"/gallery/voices/{voice_id}/preview", follow_redirects=False,
)
assert response.status_code == 200
assert "location" not in response.headers
assert response.content == source.read_bytes()
def test_gallery_preview_preserves_non_wav_content_type(client):
voice_id, _ = _gallery_voice(".mp3", b"ID3 imported voice")
response = client.get(f"/gallery/voices/{voice_id}/preview")
assert response.status_code == 200
assert response.headers["content-type"] == "audio/mpeg"