Skip to content

Commit cb218ec

Browse files
SimplicityGuyclaude
andcommitted
test(26): cover Phase 26 patch coverage gaps (94.92% → 96.36%)
Codecov flagged 68 lines missing in the Phase 26 patch. Adds 19 targeted tests across 8 files; per-file patch coverage rises sharply: - agent_worker.py: 68.29% → 97.56% (whoami retry exhaustion, role mismatch, shutdown cleanup, module-import RuntimeError on missing PHAZE_AGENT_QUEUE) - agent_client.py: 86.41% → 97.53% (upsert_files / put_metadata / put_fingerprint happy paths) - execution.py: 89.74% → 100% (4 best-effort log/PATCH failure paths) - controller.py: 78.12% → 100% (shutdown disposes engine + closes client) - agent_tracklists.py: 88.24% → 100% (409 concurrent-writer poll exhaustion) - functions.py: 88.46% → 100% (malformed mood/style prediction skips) - agent_files.py: 78.57% → 94.12% (non-music skip + enqueue failure swallow) Two log-content assertions replaced with mock-call assertions because caplog record propagation is fragile when other tests in the suite reconfigure root logger handlers. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 85cfd4f commit cb218ec

8 files changed

Lines changed: 432 additions & 0 deletions

tests/test_routers/test_agent_files.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,3 +214,40 @@ async def test_same_chunk_duplicate_paths_dedup(authenticated_client: AsyncClien
214214
assert response.status_code == 200, response.text
215215
result = await session.execute(select(sa_func.count()).select_from(FileRecord))
216216
assert result.scalar_one() == 1
217+
218+
219+
@pytest.mark.asyncio
220+
async def test_no_enqueue_for_non_music_file_type(smoke_app_and_router: tuple[AsyncClient, AsyncMock], seed_test_agent: tuple[Agent, str]) -> None:
221+
"""Non-music/video file types (e.g., .txt, .jpg) must skip the enqueue path even on INSERT."""
222+
client, mock_router = smoke_app_and_router
223+
chunk = {
224+
"files": [
225+
_make_record(path="/test/docs/readme.txt", ext="txt"),
226+
_make_record(path="/test/docs/cover.jpg", ext="jpg"),
227+
],
228+
}
229+
response = await client.post("/api/internal/agent/files", json=chunk)
230+
assert response.status_code == 200, response.text
231+
body = response.json()
232+
assert body["inserted"] == 2
233+
assert body["enqueued"] == 0
234+
mock_router.enqueue_for_agent.assert_not_awaited()
235+
236+
237+
@pytest.mark.asyncio
238+
async def test_enqueue_exception_does_not_abort_response(
239+
smoke_app_and_router: tuple[AsyncClient, AsyncMock], seed_test_agent: tuple[Agent, str]
240+
) -> None:
241+
"""Enqueue failure must be swallowed + counted as `enqueued=0` -- DB commit already succeeded."""
242+
client, mock_router = smoke_app_and_router
243+
mock_router.enqueue_for_agent.side_effect = RuntimeError("redis is sick")
244+
245+
chunk = {"files": [_make_record(path="/test/music/a.mp3")]}
246+
response = await client.post("/api/internal/agent/files", json=chunk)
247+
248+
assert response.status_code == 200, response.text
249+
body = response.json()
250+
assert body["inserted"] == 1
251+
assert body["enqueued"] == 0
252+
# Handler attempted the enqueue (got to the side_effect) before catching + continuing.
253+
mock_router.enqueue_for_agent.assert_awaited_once()

tests/test_routers/test_agent_tracklists.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -277,3 +277,46 @@ async def test_tracklist_unknown_token_returns_403(
277277
},
278278
)
279279
assert r.status_code == 403
280+
281+
282+
@pytest.mark.integration
283+
async def test_tracklist_concurrent_writer_returns_409_after_poll_exhaustion(
284+
session: AsyncSession,
285+
seed_test_agent: tuple[Agent, str],
286+
redis_client: redis_async.Redis,
287+
) -> None:
288+
"""Concurrent-writer path: req_key already locked + resp_key never populated -> 409 after poll budget."""
289+
from phaze.routers.agent_tracklists import _REQ_PREFIX, _TTL_SECONDS
290+
291+
_agent, raw_token = seed_test_agent
292+
file_id = await _seed_file(session, _agent.id)
293+
request_id = uuid.uuid4()
294+
295+
# Simulate a concurrent writer that has acquired the lock but never written the response.
296+
await redis_client.set(f"{_REQ_PREFIX}{request_id}", "1", nx=True, ex=_TTL_SECONDS)
297+
298+
# Reduce the poll budget so the test does not actually wait 500ms.
299+
import phaze.routers.agent_tracklists as router_mod
300+
301+
original_max = router_mod._CONCURRENT_POLL_MAX_ATTEMPTS
302+
original_interval = router_mod._CONCURRENT_POLL_INTERVAL_S
303+
router_mod._CONCURRENT_POLL_MAX_ATTEMPTS = 2
304+
router_mod._CONCURRENT_POLL_INTERVAL_S = 0.001
305+
try:
306+
async with _make_client(session, redis_client, raw_token) as ac:
307+
r = await ac.post(
308+
"/api/internal/agent/tracklists",
309+
json={
310+
"file_id": str(file_id),
311+
"source": "fingerprint",
312+
"external_id": f"fp-concurrent-{file_id.hex[:8]}",
313+
"request_id": str(request_id),
314+
"tracks": [{"position": 1}],
315+
},
316+
)
317+
finally:
318+
router_mod._CONCURRENT_POLL_MAX_ATTEMPTS = original_max
319+
router_mod._CONCURRENT_POLL_INTERVAL_S = original_interval
320+
321+
assert r.status_code == 409, r.text
322+
assert "duplicate in-flight request" in r.text

tests/test_services/test_agent_client.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,3 +192,59 @@ async def test_whoami_returns_agent_identity_model(client): # type: ignore[no-u
192192
assert identity.name == "File Server 01"
193193
assert identity.scan_roots == ["/data/music"]
194194
assert route.call_count == 1
195+
196+
197+
@respx.mock
198+
async def test_upsert_files_posts_chunk_and_parses_response(client): # type: ignore[no-untyped-def]
199+
from phaze.schemas.agent_files import FileUpsertChunk, FileUpsertRecord, FileUpsertResponse
200+
201+
record = FileUpsertRecord(
202+
sha256_hash="0" * 64,
203+
original_path="/m/a.mp3",
204+
original_filename="a.mp3",
205+
current_path="/m/a.mp3",
206+
file_type="mp3",
207+
file_size=1000,
208+
)
209+
chunk = FileUpsertChunk(files=[record])
210+
route = respx.post(f"{_BASE_URL}/api/internal/agent/files").mock(
211+
return_value=httpx.Response(200, json={"agent_id": "a1", "upserted": 1, "inserted": 1, "enqueued": 1}),
212+
)
213+
resp = await client.upsert_files(chunk)
214+
assert isinstance(resp, FileUpsertResponse)
215+
assert resp.agent_id == "a1"
216+
assert resp.upserted == 1
217+
assert resp.inserted == 1
218+
assert resp.enqueued == 1
219+
assert route.call_count == 1
220+
221+
222+
@respx.mock
223+
async def test_put_metadata_uses_path_id_and_parses_response(client): # type: ignore[no-untyped-def]
224+
from phaze.schemas.agent_metadata import MetadataWriteRequest, MetadataWriteResponse
225+
226+
file_id = uuid.uuid4()
227+
route = respx.put(f"{_BASE_URL}/api/internal/agent/metadata/{file_id}").mock(
228+
return_value=httpx.Response(200, json={"agent_id": "a1", "file_id": str(file_id)}),
229+
)
230+
resp = await client.put_metadata(file_id, MetadataWriteRequest(artist="X", title="Y"))
231+
assert isinstance(resp, MetadataWriteResponse)
232+
assert resp.agent_id == "a1"
233+
assert resp.file_id == file_id
234+
assert route.call_count == 1
235+
236+
237+
@respx.mock
238+
async def test_put_fingerprint_includes_engine_in_url_and_parses_response(client): # type: ignore[no-untyped-def]
239+
from phaze.schemas.agent_fingerprint import FingerprintWriteRequest, FingerprintWriteResponse
240+
241+
file_id = uuid.uuid4()
242+
engine = "audfprint"
243+
route = respx.put(f"{_BASE_URL}/api/internal/agent/fingerprints/{file_id}/{engine}").mock(
244+
return_value=httpx.Response(200, json={"agent_id": "a1", "file_id": str(file_id), "engine": engine}),
245+
)
246+
resp = await client.put_fingerprint(file_id, engine, FingerprintWriteRequest(status="success"))
247+
assert isinstance(resp, FingerprintWriteResponse)
248+
assert resp.agent_id == "a1"
249+
assert resp.engine == engine
250+
assert route.call_count == 1

tests/test_task_split.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,3 +57,36 @@ def test_agent_worker_does_not_import_phaze_database() -> None:
5757
check=False,
5858
)
5959
assert result.returncode == 0, f"agent_worker import contaminated sys.modules:\nstdout={result.stdout}\nstderr={result.stderr}"
60+
61+
62+
def test_agent_worker_module_import_fails_when_phaze_agent_queue_unset() -> None:
63+
"""Module-import-time guard: missing PHAZE_AGENT_QUEUE raises RuntimeError before SAQ event loop starts.
64+
65+
Runs in a subprocess because the module-level Queue construction is one-shot
66+
and would otherwise be cached for the whole pytest session via sys.modules.
67+
"""
68+
script = textwrap.dedent("""
69+
import os
70+
import sys
71+
os.environ["PHAZE_ROLE"] = "agent"
72+
os.environ["PHAZE_AGENT_API_URL"] = "http://localhost:8000"
73+
os.environ["PHAZE_AGENT_TOKEN"] = "phaze_agent_test-token-1234567890abcdef"
74+
os.environ["PHAZE_AGENT_SCAN_ROOTS"] = "/tmp"
75+
os.environ["PHAZE_REDIS_URL"] = "redis://localhost:6379/0"
76+
os.environ.pop("PHAZE_AGENT_QUEUE", None)
77+
try:
78+
import phaze.tasks.agent_worker # noqa: F401
79+
except RuntimeError as exc:
80+
sys.stdout.write(str(exc))
81+
sys.exit(0)
82+
sys.exit(1)
83+
""")
84+
result = subprocess.run( # noqa: S603 # trusted input
85+
[sys.executable, "-c", script],
86+
capture_output=True,
87+
text=True,
88+
timeout=20,
89+
check=False,
90+
)
91+
assert result.returncode == 0, f"expected RuntimeError at import; got rc={result.returncode}\nstdout={result.stdout}\nstderr={result.stderr}"
92+
assert "PHAZE_AGENT_QUEUE" in result.stdout

tests/test_tasks/test_agent_startup_banner.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,3 +112,71 @@ async def test_agent_worker_startup_raises_on_queue_token_mismatch(
112112
ctx: dict[str, Any] = {}
113113
with pytest.raises(RuntimeError, match="queue/token mismatch"):
114114
await aw.startup(ctx)
115+
116+
117+
@pytest.mark.asyncio
118+
async def test_whoami_with_retry_raises_runtime_error_after_exhaustion(
119+
monkeypatch: pytest.MonkeyPatch,
120+
) -> None:
121+
"""_whoami_with_retry: every attempt raises AgentApiError -> RuntimeError after budget exhausted."""
122+
from phaze.services.agent_client import AgentApiError
123+
import phaze.tasks.agent_worker as aw
124+
125+
# Shrink the retry budget to keep the test fast (~0s sleep total).
126+
monkeypatch.setattr(aw, "_WHOAMI_BACKOFF_S", (0.0, 0.0))
127+
128+
fake_client = AsyncMock()
129+
fake_client.whoami = AsyncMock(side_effect=AgentApiError("simulated down"))
130+
131+
with pytest.raises(RuntimeError, match="exhausted retry budget"):
132+
await aw._whoami_with_retry(fake_client)
133+
134+
# 2 backoff attempts + 1 final = 3 calls
135+
assert fake_client.whoami.await_count == 3
136+
137+
138+
@pytest.mark.asyncio
139+
async def test_startup_raises_when_role_is_not_agent(monkeypatch: pytest.MonkeyPatch) -> None:
140+
"""startup() must raise RuntimeError when get_settings() returns ControlSettings."""
141+
import phaze.tasks.agent_worker as aw
142+
143+
# Return a non-AgentSettings instance so the isinstance() check trips.
144+
monkeypatch.setattr(aw, "get_settings", lambda: MagicMock(name="ControlSettings"))
145+
146+
ctx: dict[str, Any] = {}
147+
with pytest.raises(RuntimeError, match="agent_worker requires PHAZE_ROLE=agent"):
148+
await aw.startup(ctx)
149+
150+
151+
@pytest.mark.asyncio
152+
async def test_shutdown_closes_pool_engines_and_client() -> None:
153+
"""shutdown() must shutdown the process pool, close each orchestrator engine, and close the api_client."""
154+
import phaze.tasks.agent_worker as aw
155+
156+
pool = MagicMock()
157+
engine_a = MagicMock()
158+
engine_a.close = AsyncMock()
159+
engine_b_no_close = MagicMock(spec=[]) # no .close attr -- exercise hasattr() False branch
160+
orchestrator = MagicMock(engines=[engine_a, engine_b_no_close])
161+
api_client = AsyncMock()
162+
api_client.close = AsyncMock()
163+
164+
ctx: dict[str, Any] = {
165+
"process_pool": pool,
166+
"fingerprint_orchestrator": orchestrator,
167+
"api_client": api_client,
168+
}
169+
await aw.shutdown(ctx)
170+
171+
pool.shutdown.assert_called_once_with(wait=True)
172+
engine_a.close.assert_awaited_once()
173+
api_client.close.assert_awaited_once()
174+
175+
176+
@pytest.mark.asyncio
177+
async def test_shutdown_tolerates_missing_ctx_keys() -> None:
178+
"""shutdown() must no-op when ctx is empty (none of the keys were set during startup)."""
179+
import phaze.tasks.agent_worker as aw
180+
181+
# Should not raise.
182+
await aw.shutdown({})

tests/test_tasks/test_controller_startup_banner.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,3 +47,30 @@ async def test_controller_startup_logs_role_banner(
4747
assert "queue=controller" in text, f"banner missing queue=controller: {text!r}"
4848
# Verify the W4 fix landed: ctx["queue"] is stashed
4949
assert "queue" in ctx, "controller.startup did not stash ctx['queue'] (W4)"
50+
51+
52+
@pytest.mark.asyncio
53+
async def test_controller_shutdown_disposes_engine_and_closes_discogs_client() -> None:
54+
"""shutdown() must dispose task_engine and close discogs_client when present in ctx."""
55+
from unittest.mock import AsyncMock
56+
57+
from phaze.tasks import controller
58+
59+
engine = MagicMock()
60+
engine.dispose = AsyncMock()
61+
discogs_client = MagicMock()
62+
discogs_client.close = AsyncMock()
63+
64+
ctx: dict[str, Any] = {"task_engine": engine, "discogs_client": discogs_client}
65+
await controller.shutdown(ctx)
66+
67+
engine.dispose.assert_awaited_once()
68+
discogs_client.close.assert_awaited_once()
69+
70+
71+
@pytest.mark.asyncio
72+
async def test_controller_shutdown_tolerates_missing_ctx_keys() -> None:
73+
"""shutdown() must no-op when startup never ran (empty ctx)."""
74+
from phaze.tasks import controller
75+
76+
await controller.shutdown({})

tests/test_tasks/test_execute_approved_batch.py

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,3 +229,107 @@ async def test_execute_approved_batch_requires_scan_roots(tmp_path: Path, monkey
229229
with pytest.raises(RuntimeError, match="scan_roots"):
230230
await execute_approved_batch({"api_client": api}, **payload.model_dump(mode="json"))
231231
api.patch_proposal_state.assert_not_awaited()
232+
233+
234+
async def test_execute_approved_batch_tolerates_post_execution_log_failure(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
235+
"""Best-effort: POST execution-log failure does NOT abort the file op (lines 105-108)."""
236+
_patch_settings(monkeypatch, [str(tmp_path)])
237+
api = _make_api_client_mock()
238+
api.post_execution_log = AsyncMock(side_effect=RuntimeError("audit log down"))
239+
240+
orig_paths, proposed_paths = _seed_files(tmp_path, 1)
241+
proposals = [
242+
ExecuteBatchProposalItem(
243+
proposal_id=uuid.uuid4(),
244+
file_id=uuid.uuid4(),
245+
original_path=str(orig_paths[0]),
246+
proposed_path=str(proposed_paths[0]),
247+
),
248+
]
249+
payload = ExecuteApprovedBatchPayload(batch_id=uuid.uuid4(), agent_id="a", proposals=proposals)
250+
result = await execute_approved_batch({"api_client": api}, **payload.model_dump(mode="json"))
251+
252+
# File op still ran and proposal still marked executed
253+
assert result["status"] == "completed"
254+
assert proposed_paths[0].exists()
255+
assert not orig_paths[0].exists()
256+
assert api.patch_proposal_state.await_args.args[1].proposal_state == "executed"
257+
258+
259+
async def test_execute_approved_batch_tolerates_patch_completed_log_failure(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
260+
"""Best-effort: PATCH completed log failure does NOT prevent SUCCESS report (lines 140-141)."""
261+
_patch_settings(monkeypatch, [str(tmp_path)])
262+
api = _make_api_client_mock()
263+
api.patch_execution_log = AsyncMock(side_effect=RuntimeError("patch died"))
264+
265+
orig_paths, proposed_paths = _seed_files(tmp_path, 1)
266+
proposals = [
267+
ExecuteBatchProposalItem(
268+
proposal_id=uuid.uuid4(),
269+
file_id=uuid.uuid4(),
270+
original_path=str(orig_paths[0]),
271+
proposed_path=str(proposed_paths[0]),
272+
),
273+
]
274+
payload = ExecuteApprovedBatchPayload(batch_id=uuid.uuid4(), agent_id="a", proposals=proposals)
275+
result = await execute_approved_batch({"api_client": api}, **payload.model_dump(mode="json"))
276+
277+
assert result["status"] == "completed"
278+
# patch_proposal_state still called with executed state
279+
assert api.patch_proposal_state.await_args.args[1].proposal_state == "executed"
280+
281+
282+
async def test_execute_approved_batch_tolerates_patch_failed_log_failure(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
283+
"""Best-effort: PATCH failed log failure does NOT prevent FAILURE report (lines 173-174)."""
284+
_patch_settings(monkeypatch, [str(tmp_path)])
285+
api = _make_api_client_mock()
286+
287+
# First PATCH (sets in_progress is via POST, second PATCH after file-op-failure goes to status=failed)
288+
# Make patch_execution_log raise on EVERY call so the "failed log" branch raises.
289+
api.patch_execution_log = AsyncMock(side_effect=RuntimeError("patch died"))
290+
291+
# Force file-op failure via missing source.
292+
missing = tmp_path / "missing.mp3"
293+
proposed = tmp_path / "new" / "missing.mp3"
294+
proposals = [
295+
ExecuteBatchProposalItem(
296+
proposal_id=uuid.uuid4(),
297+
file_id=uuid.uuid4(),
298+
original_path=str(missing),
299+
proposed_path=str(proposed),
300+
),
301+
]
302+
payload = ExecuteApprovedBatchPayload(batch_id=uuid.uuid4(), agent_id="a", proposals=proposals)
303+
result = await execute_approved_batch({"api_client": api}, **payload.model_dump(mode="json"))
304+
305+
assert result["error_count"] == 1
306+
# Failure still reported via patch_proposal_state(failed)
307+
assert api.patch_proposal_state.await_args.args[1].proposal_state == "failed"
308+
309+
310+
async def test_execute_approved_batch_tolerates_failure_report_failure(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
311+
"""Last-line defense: even if patch_proposal_state(failed) raises, the batch returns cleanly (lines 189-192)."""
312+
_patch_settings(monkeypatch, [str(tmp_path)])
313+
api = _make_api_client_mock()
314+
api.patch_proposal_state = AsyncMock(side_effect=RuntimeError("state-machine API down"))
315+
316+
# Force file-op failure -- this exercises the failed-PATCH-of-failure path.
317+
missing = tmp_path / "missing.mp3"
318+
proposed = tmp_path / "new" / "missing.mp3"
319+
proposals = [
320+
ExecuteBatchProposalItem(
321+
proposal_id=uuid.uuid4(),
322+
file_id=uuid.uuid4(),
323+
original_path=str(missing),
324+
proposed_path=str(proposed),
325+
),
326+
]
327+
payload = ExecuteApprovedBatchPayload(batch_id=uuid.uuid4(), agent_id="a", proposals=proposals)
328+
329+
# Should NOT raise -- the inner try/except wraps the failure report.
330+
result = await execute_approved_batch({"api_client": api}, **payload.model_dump(mode="json"))
331+
332+
assert result["processed_count"] == 1
333+
assert result["error_count"] == 1
334+
# Handler reached patch_proposal_state (the side_effect fired) before swallowing.
335+
api.patch_proposal_state.assert_awaited_once()

0 commit comments

Comments
 (0)