Files
shawn 3e55ea8a9d Fix multi-word query URL encoding for Tone3000 search
Replace spaces with PostgREST wildcards in ilike filter values so
multi-word queries like 'vox ac30' produce valid URLs.

Root cause: PostgREST filter parser doesn't handle spaces URL-encoded
as '+' in query parameter values, returning 400 Bad Request.

Fix: query.replace(' ', '*') converts spaces to PostgREST wildcards
before inserting into ilike.* pattern. 'vox ac30' becomes
ilike.*vox*ac30* -> ILIKE '%vox%ac30%'.

Changes:
- src/system/tonedownload.py: fix search_models() and search_irs()
- tests/test_tonedownload.py: update test_multi_word_query assertion
2026-06-12 12:43:55 -04:00

717 lines
24 KiB
Python

"""Tests for the Tone3000 downloader client.
Tests use mocked HTTP responses so no network access is needed.
The Web UI integration tests cover the /api/models/tonedownload/* and
/api/irs/tonedownload/* endpoints via the TestClient.
Run with:
pytest tests/test_tonedownload.py -v
"""
from __future__ import annotations
import json
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from src.system.tonedownload import (
Tone3000Client,
ModelResult,
IRResult,
format_size,
discover_anon_key,
CACHE_TTL,
)
# ── Test constants ─────────────────────────────────────────────────────
FAKE_ANON_KEY = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.test"
SAMPLE_MODEL_ROW = {
"id": 1,
"name": "Marshall JCM800",
"model_url": "https://api.tone3000.com/storage/v1/object/public/models/test.nam",
"size": 512000,
"pack_name": "Marshall Pack",
"user_id": "u1",
"tone_id": 10,
"architecture_version": "1",
"created_at": "2025-01-01T00:00:00Z",
}
SAMPLE_TONE_ROW = {
"id": 10,
"title": "Marshall JCM800 Sound",
"description": "Classic rock tone",
"gear": "amp",
"images": ["https://example.com/thumb.jpg"],
}
SAMPLE_IR_TONE_ROW = {
"id": 20,
"title": "V30 Cabinet",
"description": "4x12 V30 IR",
"gear": "ir",
"images": [],
}
SAMPLE_IR_MODEL_ROW = {
"id": 100,
"name": "v30_cabinet.wav",
"model_url": "https://api.tone3000.com/storage/v1/object/public/models/v30.wav",
"size": 256000,
"user_id": "u2",
"tone_id": 20,
"created_at": "2025-02-01T00:00:00Z",
}
SAMPLE_USER_ROW = {
"id": "u1",
"username": "testuser",
"avatar_url": "https://example.com/avatar.png",
"display_name": "Test User",
}
# ── Fixtures ───────────────────────────────────────────────────────────
@pytest.fixture
def mock_session():
"""Create a mock aiohttp.ClientSession that returns empty lists by default."""
session = MagicMock()
resp = MagicMock()
resp.status = 200
resp.json = AsyncMock(return_value=[])
resp.text = AsyncMock(return_value="[]")
resp.__aenter__ = AsyncMock(return_value=resp)
resp.__aexit__ = AsyncMock(return_value=None)
session.get = MagicMock(return_value=resp)
session.closed = False
session.close = AsyncMock()
return session
@pytest.fixture
def client(mock_session):
"""Tone3000Client with a mocked session and known anon key."""
c = Tone3000Client(anon_key=FAKE_ANON_KEY, session=mock_session)
yield c
# Teardown: ensure close is called if the test doesn't
if c._session and not c._session.closed:
pass # tests manage session lifecycle
# ── Data class tests ───────────────────────────────────────────────────
class TestModelResult:
def test_basic_attributes(self):
m = ModelResult(id=1, name="test.nam", download_url="https://example.com/test.nam")
assert m.id == 1
assert m.filename == "test.nam"
assert m.display_name == "test.nam"
assert m.is_ir is False
assert m.thumbnail_url is None
def test_with_tone_title(self):
m = ModelResult(
id=1,
name="test.nam",
download_url="https://example.com/test.nam",
tone_title="My Tone",
)
assert m.display_name == "My Tone"
def test_with_images(self):
m = ModelResult(
id=1,
name="test.nam",
download_url="https://example.com/test.nam",
tone_images=["https://example.com/thumb.jpg"],
)
assert m.thumbnail_url == "https://example.com/thumb.jpg"
def test_wav_is_ir(self):
m = ModelResult(id=1, name="ir.wav", download_url="https://example.com/ir.wav")
assert m.is_ir is True
def test_ir_flag_from_gear(self):
m = ModelResult(id=1, name="test.wav", download_url="https://example.com/test.wav", tone_gear="ir")
assert m.is_ir is True
class TestIRResult:
def test_basic_attributes(self):
ir = IRResult(id=1, name="ir.wav", download_url="https://example.com/ir.wav")
assert ir.is_ir is True
def test_sample_rate(self):
ir = IRResult(id=1, name="ir.wav", download_url="https://example.com/ir.wav", sample_rate=48000)
assert ir.sample_rate == 48000
assert ir.is_ir is True
# ── format_size tests ──────────────────────────────────────────────────
class TestFormatSize:
def test_none(self):
assert format_size(None) == "Unknown"
def test_bytes(self):
assert format_size(500) == "0.5 KB"
def test_megabytes(self):
assert format_size(2 * 1024 * 1024) == "2.0 MB"
def test_large_megabytes(self):
assert format_size(15 * 1024 * 1024) == "15.0 MB"
def test_edge_kb(self):
assert format_size(1023) == "1.0 KB"
def test_edge_mb(self):
assert format_size(1024 * 1024) == "1.0 MB"
def test_string_input(self):
"""format_size should handle string inputs (Supabase can return string)."""
assert format_size("500") == "0.5 KB"
assert format_size("2097152") == "2.0 MB"
assert format_size("1024") == "1.0 KB"
def test_invalid_string(self):
"""format_size should handle unparseable string inputs."""
assert format_size("not-a-number") == "Unknown"
assert format_size("") == "Unknown"
# ── Client initialization ──────────────────────────────────────────────
class TestClientInit:
def test_default_anon_key(self):
"""Should use the hardcoded constant when no key provided."""
c = Tone3000Client()
assert c._anon_key is not None
assert len(c._anon_key) > 50
def test_custom_anon_key(self):
c = Tone3000Client(anon_key=FAKE_ANON_KEY)
assert c._anon_key == FAKE_ANON_KEY
def test_custom_dirs(self):
models_dir = Path("/tmp/test-models")
irs_dir = Path("/tmp/test-irs")
c = Tone3000Client(
anon_key=FAKE_ANON_KEY,
models_dir=models_dir,
irs_dir=irs_dir,
)
assert c._models_dir == models_dir
assert c._irs_dir == irs_dir
# ── Session management ─────────────────────────────────────────────────
class TestSession:
def test_get_session_creates_if_none(self, client):
client._session = None
assert client._session is None
assert client._anon_key == FAKE_ANON_KEY
@pytest.mark.asyncio
async def test_close_no_session(self, client):
client._session = None
await client.close()
@pytest.mark.asyncio
async def test_close_closed_session(self, client, mock_session):
mock_session.closed = True
await client.close()
# ── Caching ────────────────────────────────────────────────────────────
class TestCaching:
def test_cache_miss(self, client):
assert client._get_cached("test:key") is None
def test_cache_hit(self, client):
data = [{"id": 1, "name": "test"}]
client._set_cache("test:key", data)
result = client._get_cached("test:key")
assert result == data
def test_cache_expiry(self, client):
data = [{"id": 1}]
client._set_cache("test:expire", data)
client._cache["test:expire"] = (0.0, data)
assert client._get_cached("test:expire") is None
# ── API calls (mocked) ─────────────────────────────────────────────────
class TestSupabaseGet:
@pytest.mark.asyncio
async def test_success(self, client):
mock_resp = MagicMock()
mock_resp.status = 200
mock_resp.json = AsyncMock(return_value=[{"id": 1}])
mock_resp.__aenter__ = AsyncMock(return_value=mock_resp)
mock_resp.__aexit__ = AsyncMock()
client._session.get = MagicMock(return_value=mock_resp)
result = await client._supabase_get("models", {"limit": "1"})
assert result == [{"id": 1}]
@pytest.mark.asyncio
async def test_error_status(self, client):
mock_resp = MagicMock()
mock_resp.status = 500
mock_resp.text = AsyncMock(return_value="Internal error")
mock_resp.__aenter__ = AsyncMock(return_value=mock_resp)
mock_resp.__aexit__ = AsyncMock()
client._session.get = MagicMock(return_value=mock_resp)
result = await client._supabase_get("models")
assert result == []
@pytest.mark.asyncio
async def test_network_error(self, client):
from aiohttp import ClientError
client._session.get = MagicMock(side_effect=ClientError("Connection refused"))
result = await client._supabase_get("models")
assert result == []
@pytest.mark.asyncio
async def test_single_object_response(self, client):
"""Should wrap a single object in a list."""
mock_resp = MagicMock()
mock_resp.status = 200
mock_resp.json = AsyncMock(return_value={"id": 1})
mock_resp.__aenter__ = AsyncMock(return_value=mock_resp)
mock_resp.__aexit__ = AsyncMock()
client._session.get = MagicMock(return_value=mock_resp)
result = await client._supabase_get("models", {"id": "eq.1"})
assert result == [{"id": 1}]
# ── Ping ───────────────────────────────────────────────────────────────
class TestPing:
@pytest.mark.asyncio
async def test_online(self, client):
mock_resp = MagicMock()
mock_resp.status = 200
mock_resp.json = AsyncMock(return_value=[{"id": 1}])
mock_resp.__aenter__ = AsyncMock(return_value=mock_resp)
mock_resp.__aexit__ = AsyncMock()
client._session.get = MagicMock(return_value=mock_resp)
assert await client.ping() is True
@pytest.mark.asyncio
async def test_offline(self, client):
from aiohttp import ClientError
client._session.get = MagicMock(side_effect=ClientError("Timeout"))
assert await client.ping() is False
# ── Search ─────────────────────────────────────────────────────────────
class TestSearchModels:
@pytest.mark.asyncio
async def test_search_returns_results(self, client):
async def mock_get(path, params=None):
if path == "models" and params and "name" in params:
return [SAMPLE_MODEL_ROW]
if path == "users":
return [SAMPLE_USER_ROW]
if path == "tones":
return [SAMPLE_TONE_ROW]
return []
client._supabase_get = mock_get
results = await client.search_models("marshall", page=0)
assert len(results) == 1
assert results[0].name == "Marshall JCM800"
assert results[0].display_name == "Marshall JCM800 Sound"
assert results[0].author_username == "testuser"
@pytest.mark.asyncio
async def test_search_cached(self, client):
call_count = 0
async def mock_get(path, params=None):
nonlocal call_count
call_count += 1
if path == "models":
return [SAMPLE_MODEL_ROW]
if path == "users":
return [SAMPLE_USER_ROW]
if path == "tones":
return [SAMPLE_TONE_ROW]
return []
client._supabase_get = mock_get
await client.search_models("marshall")
first_count = call_count
await client.search_models("marshall")
assert call_count == first_count
@pytest.mark.asyncio
async def test_force_refresh(self, client):
call_count = 0
async def mock_get(path, params=None):
nonlocal call_count
call_count += 1
if path == "models":
return [SAMPLE_MODEL_ROW]
if path == "users":
return [SAMPLE_USER_ROW]
if path == "tones":
return [SAMPLE_TONE_ROW]
return []
client._supabase_get = mock_get
await client.search_models("marshall")
await client.search_models("marshall", force_refresh=True)
assert call_count >= 2
@pytest.mark.asyncio
async def test_empty_query(self, client):
results = await client.search_models("nonexistent_xyz123", page=0)
assert isinstance(results, list)
@pytest.mark.asyncio
async def test_multi_word_query(self, client):
"""Multi-word queries should convert spaces to wildcards."""
captured_params = []
async def mock_get(path, params=None):
if path == "models" and params and "name" in params:
captured_params.append(params)
return [SAMPLE_MODEL_ROW]
if path == "users":
return [SAMPLE_USER_ROW]
if path == "tones":
return [SAMPLE_TONE_ROW]
return []
client._supabase_get = mock_get
results = await client.search_models("vox ac30", page=0)
assert len(results) == 1
assert results[0].name == "Marshall JCM800"
# Verify the Supabase param has spaces replaced with wildcards
assert len(captured_params) >= 1
name_param = captured_params[0].get("name", "")
assert "ilike.*vox*ac30*" in name_param
# Should NOT contain the raw space (which PostgREST filter parser
# can't handle in URL query values)
assert "vox ac30" not in name_param
class TestSearchIRs:
@pytest.mark.asyncio
async def test_search_returns_results(self, client):
async def mock_get(path, params=None):
if path == "tones" and "ir" in str(params.get("gear", "")):
return [SAMPLE_IR_TONE_ROW]
if path == "models":
return [SAMPLE_IR_MODEL_ROW]
if path == "users":
return [{"id": "u2", "username": "irtest", "avatar_url": None, "display_name": None}]
return []
client._supabase_get = mock_get
results = await client.search_irs("v30", page=0)
assert len(results) >= 1
assert results[0].is_ir is True
assert results[0].tone_title == "V30 Cabinet"
@pytest.mark.asyncio
async def test_empty_results(self, client):
async def mock_get(path, params=None):
if path == "tones":
return []
return []
client._supabase_get = mock_get
results = await client.search_irs("nonexistent_ir", page=0)
assert results == []
# ── Trending ───────────────────────────────────────────────────────────
class TestTrending:
@pytest.mark.asyncio
async def test_trending_models(self, client):
async def mock_get(path, params=None):
if path == "models":
return [SAMPLE_MODEL_ROW]
if path == "users":
return [SAMPLE_USER_ROW]
if path == "tones":
return [SAMPLE_TONE_ROW]
return []
client._supabase_get = mock_get
results = await client.get_trending_models(limit=3)
assert len(results) == 1
@pytest.mark.asyncio
async def test_trending_irs(self, client):
async def mock_get(path, params=None):
if path == "tones":
return [SAMPLE_IR_TONE_ROW]
if path == "models":
return [SAMPLE_IR_MODEL_ROW]
if path == "users":
return [{"id": "u2", "username": "irtest", "avatar_url": None}]
return []
client._supabase_get = mock_get
results = await client.get_trending_irs(limit=3)
assert len(results) == 1
# ── Download ───────────────────────────────────────────────────────────
class TestDownload:
@pytest.mark.asyncio
async def test_download_model_success(self, client, tmp_path):
client._models_dir = tmp_path
async def _chunks():
yield b"testdata"
mock_resp = MagicMock()
mock_resp.status = 200
mock_resp.headers = {"Content-Length": "9"}
mock_resp.content = MagicMock()
mock_resp.content.iter_chunked = MagicMock(side_effect=lambda cs: _chunks())
mock_resp.__aenter__ = AsyncMock(return_value=mock_resp)
mock_resp.__aexit__ = AsyncMock()
client._session.get = MagicMock(return_value=mock_resp)
model = ModelResult(id=1, name="test_model", download_url="https://example.com/test.nam")
path = await client.download_model(model)
assert path is not None
assert path.exists()
assert path.suffix == ".nam"
assert path.read_bytes() == b"testdata"
@pytest.mark.asyncio
async def test_download_model_no_url(self, client):
model = ModelResult(id=1, name="test", download_url="")
path = await client.download_model(model)
assert path is None
@pytest.mark.asyncio
async def test_download_model_http_error(self, client, tmp_path):
client._models_dir = tmp_path
mock_resp = MagicMock()
mock_resp.status = 404
mock_resp.__aenter__ = AsyncMock(return_value=mock_resp)
mock_resp.__aexit__ = AsyncMock()
client._session.get = MagicMock(return_value=mock_resp)
model = ModelResult(id=1, name="test", download_url="https://example.com/test.nam")
path = await client.download_model(model)
assert path is None
@pytest.mark.asyncio
async def test_download_ir_success(self, client, tmp_path):
client._irs_dir = tmp_path
async def _chunks():
yield b"wavdata"
mock_resp = MagicMock()
mock_resp.status = 200
mock_resp.headers = {"Content-Length": "9"}
mock_resp.content = MagicMock()
mock_resp.content.iter_chunked = MagicMock(side_effect=lambda cs: _chunks())
mock_resp.__aenter__ = AsyncMock(return_value=mock_resp)
mock_resp.__aexit__ = AsyncMock()
client._session.get = MagicMock(return_value=mock_resp)
ir = IRResult(id=1, name="cabinet", download_url="https://example.com/cabinet.wav")
path = await client.download_ir(ir)
assert path is not None
assert path.exists()
assert path.suffix == ".wav"
assert path.read_bytes() == b"wavdata"
@pytest.mark.asyncio
async def test_download_progress(self, client, tmp_path):
client._models_dir = tmp_path
async def _chunks():
yield b"data1"
yield b"data2"
mock_resp = MagicMock()
mock_resp.status = 200
mock_resp.headers = {"Content-Length": "12"}
mock_resp.content = MagicMock()
mock_resp.content.iter_chunked = MagicMock(side_effect=lambda cs: _chunks())
mock_resp.__aenter__ = AsyncMock(return_value=mock_resp)
mock_resp.__aexit__ = AsyncMock()
client._session.get = MagicMock(return_value=mock_resp)
progress_values = []
def cb(ratio):
progress_values.append(ratio)
model = ModelResult(id=1, name="test", download_url="https://example.com/test.nam")
path = await client.download_model(model, progress_callback=cb)
assert path is not None
assert len(progress_values) > 0
# ── User/Tone enrichment ──────────────────────────────────────────────
class TestEnrichment:
@pytest.mark.asyncio
async def test_get_user_sync(self, client):
"""User lookup works (sync test via mock)."""
from src.system.tonedownload import Tone3000Client
c = Tone3000Client(anon_key=FAKE_ANON_KEY)
result = await c._get_user("")
assert result is None
await c.close()
@pytest.mark.asyncio
async def test_get_tone_sync(self, client):
from src.system.tonedownload import Tone3000Client
c = Tone3000Client(anon_key=FAKE_ANON_KEY)
result = await c._get_tone(9999)
assert result is None
await c.close()
@pytest.mark.asyncio
async def test_get_user_cached(self, client):
async def mock_get(path, params=None):
return [SAMPLE_USER_ROW]
client._supabase_get = mock_get
user = await client._get_user("u1")
assert user["username"] == "testuser"
# Second call — cached
user2 = await client._get_user("u1")
assert user2["username"] == "testuser"
@pytest.mark.asyncio
async def test_get_user_empty_id(self, client):
user = await client._get_user("")
assert user is None
@pytest.mark.asyncio
async def test_get_tone_cached(self, client):
async def mock_get(path, params=None):
return [SAMPLE_TONE_ROW]
client._supabase_get = mock_get
tone = await client._get_tone(10)
assert tone["title"] == "Marshall JCM800 Sound"
# Second call — cached
tone2 = await client._get_tone(10)
assert tone2["title"] == "Marshall JCM800 Sound"
# ── Anon key discovery ────────────────────────────────────────────────
class TestAnonKeyDiscovery:
@pytest.mark.asyncio
async def test_discover_timeout(self, client):
mock_resp = MagicMock()
mock_resp.__aenter__ = AsyncMock(side_effect=TimeoutError("Timed out"))
mock_resp.__aexit__ = AsyncMock()
client._session.get = MagicMock(return_value=mock_resp)
key = await discover_anon_key(client._session)
assert key is None
# ── Integration via real HTTP (optional) ───────────────────────────────
@pytest.mark.skipif(
True,
reason="Real HTTP test — enable manually to verify against live Tone3000 API",
)
class TestRealHTTP:
"""Tests that hit the real Tone3000 API."""
@pytest.mark.asyncio
async def test_ping(self):
c = Tone3000Client()
ok = await c.ping()
assert ok is True
await c.close()
@pytest.mark.asyncio
async def test_search_models(self):
c = Tone3000Client()
results = await c.search_models("marshall", page=0, page_size=5)
assert len(results) > 0
for r in results:
assert r.display_name
assert r.download_url
await c.close()
@pytest.mark.asyncio
async def test_search_irs(self):
c = Tone3000Client()
results = await c.search_irs("v30", page=0, page_size=5)
assert len(results) > 0
for r in results:
assert r.is_ir is True
await c.close()
@pytest.mark.asyncio
async def test_trending_models(self):
c = Tone3000Client()
results = await c.get_trending_models(limit=5)
assert len(results) > 0
await c.close()
@pytest.mark.asyncio
async def test_download(self, tmp_path):
c = Tone3000Client(models_dir=tmp_path, irs_dir=tmp_path)
results = await c.search_models("marshall", page=0, page_size=1)
if results:
path = await c.download_model(results[0])
assert path is not None
assert path.exists()
assert path.stat().st_size > 1000
await c.close()