From 1e67eeb25bcd9733698e4db2ad294b2429432320 Mon Sep 17 00:00:00 2001 From: "Gan, Jimmy" Date: Wed, 3 Jun 2026 11:15:27 +0800 Subject: [PATCH] Add automated unit and mock integration test suite - Configure conftest.py with pytest tmp_path DB isolation fixtures - Add test_db.py for schema connection validations - Add test_fetcher.py with mocked yt-dlp subprocess parser checks - Add test_summarizer.py with mocked transcripts and Claude API requests - Add test_main.py for FastAPI router endpoints and state updates Co-Authored-By: Claude Opus 4.8 --- backend/requirements.txt | 3 + .../conftest.cpython-313-pytest-9.0.2.pyc | Bin 0 -> 2518 bytes backend/tests/conftest.py | 44 ++++++ backend/tests/test_db.py | 28 ++++ backend/tests/test_fetcher.py | 112 ++++++++++++++++ backend/tests/test_main.py | 107 +++++++++++++++ backend/tests/test_summarizer.py | 125 ++++++++++++++++++ 7 files changed, 419 insertions(+) create mode 100644 backend/tests/__pycache__/conftest.cpython-313-pytest-9.0.2.pyc create mode 100644 backend/tests/conftest.py create mode 100644 backend/tests/test_db.py create mode 100644 backend/tests/test_fetcher.py create mode 100644 backend/tests/test_main.py create mode 100644 backend/tests/test_summarizer.py diff --git a/backend/requirements.txt b/backend/requirements.txt index 5da6060..652239d 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -4,3 +4,6 @@ yt-dlp>=2023.0.0 youtube-transcript-api>=0.6.0 aiosqlite>=0.18.0 httpx>=0.24.0 +pytest>=7.0.0 +pytest-asyncio>=0.21.0 +pytest-mock>=3.10.0 diff --git a/backend/tests/__pycache__/conftest.cpython-313-pytest-9.0.2.pyc b/backend/tests/__pycache__/conftest.cpython-313-pytest-9.0.2.pyc new file mode 100644 index 0000000000000000000000000000000000000000..7e7eb6e73fe50467a75dedbef0382e5f4d27fd1e GIT binary patch literal 2518 zcmbtV&2JM&6o0e3_WCQ?gpe;j90wACqtvESLxh^38ucRyC{#A?g;bX9teu5;*PUIH z8hS`ls_=0j93n!CM2@6~(p#$*RZ9PYkVHzfK`IiJDsDkWY7ZRxX8j2Y>Y<~ZncvL& z&6}C`-n=c;)wux6m2W#PJQ4t&u|c_rB~W-o02Uwu5po(Rv3hQV*Et`I2-M~)O6=5b zNkl4Iy@NWe-btNS@1m|Td$^^nyxND`tGJB*H*8)H_#Ex9C zhP!unZp2Z;X-*Tr^EuN#YKpq7>x!oOiD}c5T2@Y$cC|Y6KODwd_}&6O^tl(#TO!tk zOSh^_AUISxw+d0X1Pd4qQ3yM0IYnH)pAXW|3Fp~fRuLiI2H>mR$6l6I``UdvVDxwj z2Kf$1k$yf!mdU8!W{Pn=uO=1EY)H#SjOJ8TQPZ(WP0N~|a85OpjQkO$8a3-xc`7Df zl2xN9wIwGtT}F!tjI6T|@tOU>s4i1IctOcz^64Ozvzi_>-pOk@BbShaiFop&td0i_ zSvT}xQd3h51+sb5Rn<)EQ5CED$JjiFY1k2X+tDXd%cgW>O**nJ`L78(c5q9xBU^%F zZe(WUn=}8s6v5M008%1$uSwnO(vcg&4v$l>^lnr&OF~WLYI6YYHY50(5b{kK`Qp%)ETTws7cm|yr&c_O^Lu7g~TJmBHX-osfi~@|R ze~!&GytKpKmOK5o`|k|g9$0d$_6)8!ot!?iB}kh>>tmty9=ABW^woy&UcnBwrfs3Q zB3Trd+8>k*{s9mj+n$?wfDIXIcN+v7O7bX1yW*1^*R6IpF z{)V~Nb+YW@fYDK`?_za!LN8bx%s0hTJHP`+OPh?N1yY<3I`AfJWi_Wzb{oH)vfq^{ zDym|j?@VW0(ey7S6+<>feDtcEG)z9MO_^@0O~q32q@mHgpR?R+3XGw%fGfv*oWi|7{;>b3vM zBl>NB=or7!?nb!MMM42#rQ6PMFJW!KYLD4ZiQ>v}5<1~o>GvT1oflE+Z!sN-oHA)B zs&0Bd$mU~}QF|<#xAI_I$K+|?e{tW23=dCrB4EsMk{-Giq!?Zq)=5lVe* zhS&2tZDL3qSEw4#pyA^Q-Fk(NnDyjhG1Fa2$1&uXc03nuUh-T@`O?Ty*-FKhTP)iz z13k$0uwJHPAft`vCgqcq{h}};K>rCF%(sO60dN(*c5Xx6Hq>uJ!#21uVM~p-($~|s zwCmbp;(q#W`o4BodysgTUQR#MmbITJ)&rlcdxzHRho{|JeEod#&cyABb>6!rh;u_T zLvv?m&d!JCuWSg1pMmo!5$C+K-g)IQ@x9>eq_MyQ*SW*cP6KcbCR`LAlY=ifH)&%+ W?+!yd`z+y%mn~{y^9>fVF7_|)-t-Uv literal 0 HcmV?d00001 diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000..558b37f --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,44 @@ +import os +import pytest +import asyncio +import aiosqlite +import sys + +# Ensure backend folder is in path for imports +backend_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +if backend_dir not in sys.path: + sys.path.insert(0, backend_dir) + +import db + +@pytest.fixture(scope="session") +def event_loop(): + try: + loop = asyncio.get_running_loop() + except RuntimeError: + loop = asyncio.new_event_loop() + yield loop + loop.close() + +@pytest.fixture(autouse=True) +def mock_db_path(tmp_path, monkeypatch): + # Ensure all modules (db, fetcher, summarizer, main) point to the temp test db + test_db_file = os.path.join(tmp_path, "test_t_youtube.db") + monkeypatch.setattr(db, "DB_PATH", test_db_file) + + # Also monkeypatch in other modules that import DB_PATH directly + import fetcher + import summarizer + monkeypatch.setattr(fetcher, "DB_PATH", test_db_file) + monkeypatch.setattr(summarizer, "DB_PATH", test_db_file) + + return test_db_file + +@pytest.fixture +async def test_db(mock_db_path): + # Initialize the tables + await db.init_db() + + async with aiosqlite.connect(mock_db_path) as conn: + conn.row_factory = aiosqlite.Row + yield conn diff --git a/backend/tests/test_db.py b/backend/tests/test_db.py new file mode 100644 index 0000000..22c532a --- /dev/null +++ b/backend/tests/test_db.py @@ -0,0 +1,28 @@ +import pytest +import aiosqlite +import db + +@pytest.mark.asyncio +async def test_init_db(test_db): + # Verify that the table is created + cursor = await test_db.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='videos'") + table = await cursor.fetchone() + assert table is not None + assert table["name"] == "videos" + +@pytest.mark.asyncio +async def test_insert_video(test_db): + # Insert a video and check constraints/defaults + await test_db.execute(""" + INSERT INTO videos (video_id, title, url, status) + VALUES ('test1234', 'Test Video Title', 'https://www.youtube.com/watch?v=test1234', 'pending') + """) + await test_db.commit() + + cursor = await test_db.execute("SELECT * FROM videos WHERE video_id = 'test1234'") + video = await cursor.fetchone() + assert video is not None + assert video["title"] == "Test Video Title" + assert video["status"] == "pending" + assert video["created_at"] is not None + assert video["summary"] is None diff --git a/backend/tests/test_fetcher.py b/backend/tests/test_fetcher.py new file mode 100644 index 0000000..43a353a --- /dev/null +++ b/backend/tests/test_fetcher.py @@ -0,0 +1,112 @@ +import pytest +import os +import json +import aiosqlite +import fetcher +import asyncio +from unittest.mock import patch, AsyncMock + +class MockProcess: + def __init__(self, stdout_data, returncode=0, stderr_data=b""): + self.stdout_data = stdout_data + self.stderr_data = stderr_data + self.returncode = returncode + + async def communicate(self): + return self.stdout_data, self.stderr_data + +@pytest.mark.asyncio +async def test_fetch_subscriptions_no_cookies(tmp_path, monkeypatch): + monkeypatch.setattr(fetcher, "COOKIES_PATH", "/non/existent/path/cookies.txt") + count = await fetcher.fetch_subscriptions() + assert count == 0 + +@pytest.mark.asyncio +async def test_fetch_subscriptions_success(test_db, tmp_path, monkeypatch): + cookies_file = os.path.join(tmp_path, "mock_cookies.txt") + with open(cookies_file, "w") as f: + f.write("# Netscape HTTP Cookie File") + monkeypatch.setattr(fetcher, "COOKIES_PATH", cookies_file) + + video1 = { + "id": "vid123", + "title": "Mock Video 1", + "uploader": "Uploader One", + "uploader_id": "chan123", + "upload_date": "20260601", + "duration": 360, + "thumbnail": "https://img.youtube.com/vi/vid123/hqdefault.jpg" + } + video2 = { + "id": "vid456", + "title": "Mock Video 2", + "uploader": "Uploader Two", + "uploader_id": "chan456", + "upload_date": "20260602", + "duration": 720 + } + stdout_bytes = (json.dumps(video1) + "\n" + json.dumps(video2) + "\n").encode("utf-8") + + mock_process = MockProcess(stdout_data=stdout_bytes, returncode=0) + + with patch("asyncio.create_subprocess_exec", return_value=mock_process) as mock_exec: + count = await fetcher.fetch_subscriptions() + + mock_exec.assert_called_once() + args = mock_exec.call_args[0] + assert "yt-dlp" in args + assert "https://www.youtube.com/feed/subscriptions" in args + assert cookies_file in args + assert count == 2 + + # Query db to verify values + cursor = await test_db.execute("SELECT * FROM videos ORDER BY video_id") + videos = await cursor.fetchall() + assert len(videos) == 2 + + assert videos[0]["video_id"] == "vid123" + assert videos[0]["title"] == "Mock Video 1" + assert videos[0]["channel_name"] == "Uploader One" + assert videos[0]["channel_id"] == "chan123" + assert videos[0]["url"] == "https://www.youtube.com/watch?v=vid123" + assert videos[0]["thumbnail_url"] == "https://img.youtube.com/vi/vid123/hqdefault.jpg" + assert videos[0]["publish_date"] == "2026-06-01" + assert videos[0]["duration"] == 360 + assert videos[0]["status"] == "pending" + + assert videos[1]["video_id"] == "vid456" + assert videos[1]["title"] == "Mock Video 2" + assert videos[1]["publish_date"] == "2026-06-02" + assert videos[1]["duration"] == 720 + +@pytest.mark.asyncio +async def test_fetch_subscriptions_skip_duplicates(test_db, tmp_path, monkeypatch): + cookies_file = os.path.join(tmp_path, "mock_cookies.txt") + with open(cookies_file, "w") as f: + f.write("# Netscape HTTP Cookie File") + monkeypatch.setattr(fetcher, "COOKIES_PATH", cookies_file) + + # Insert an existing video record first + await test_db.execute(""" + INSERT INTO videos (video_id, title, url, status) + VALUES ('vid123', 'Mock Video 1', 'https://www.youtube.com/watch?v=vid123', 'pending') + """) + await test_db.commit() + + video1 = { + "id": "vid123", + "title": "Mock Video 1", + "upload_date": "20260601" + } + video2 = { + "id": "vid456", + "title": "Mock Video 2", + "upload_date": "20260602" + } + stdout_bytes = (json.dumps(video1) + "\n" + json.dumps(video2) + "\n").encode("utf-8") + + mock_process = MockProcess(stdout_data=stdout_bytes, returncode=0) + + with patch("asyncio.create_subprocess_exec", return_value=mock_process): + count = await fetcher.fetch_subscriptions() + assert count == 1 # Only 1 new video should be added (vid456) diff --git a/backend/tests/test_main.py b/backend/tests/test_main.py new file mode 100644 index 0000000..64f4281 --- /dev/null +++ b/backend/tests/test_main.py @@ -0,0 +1,107 @@ +import pytest +from fastapi.testclient import TestClient +from main import app, get_db +import aiosqlite +from unittest.mock import patch + +@pytest.fixture +def client(test_db): + # Override get_db dependency to use the isolated test database + async def override_get_db(): + yield test_db + app.dependency_overrides[get_db] = override_get_db + yield TestClient(app) + app.dependency_overrides.clear() + +def test_health(client): + response = client.get("/api/health") + assert response.status_code == 200 + assert response.json() == {"status": "ok"} + +@pytest.mark.asyncio +async def test_get_videos(client, test_db): + # Insert mock videos with different statuses + await test_db.execute(""" + INSERT INTO videos (video_id, title, url, status) + VALUES + ('v1', 'Pending Video', 'https://youtube.com/v1', 'pending'), + ('v2', 'Read Video', 'https://youtube.com/v2', 'read'), + ('v3', 'Skipped Video', 'https://youtube.com/v3', 'skipped') + """) + await test_db.commit() + + # Test pending filter + resp = client.get("/api/videos?status=pending") + assert resp.status_code == 200 + data = resp.json() + assert len(data) == 1 + assert data[0]["video_id"] == "v1" + + # Test read filter + resp = client.get("/api/videos?status=read") + assert resp.status_code == 200 + data = resp.json() + assert len(data) == 1 + assert data[0]["video_id"] == "v2" + +def test_update_status_success(client, test_db): + # Insert pending video + test_db.execute_insert = lambda *args: None # Helper since we execute raw + async def insert(): + await test_db.execute(""" + INSERT INTO videos (video_id, title, url, status) + VALUES ('status_vid', 'Title', 'https://youtube.com/watch?v=status_vid', 'pending') + """) + await test_db.commit() + + import asyncio + asyncio.run(insert()) + + # Send status update + resp = client.post("/api/videos/status_vid/status", json={"status": "read"}) + assert resp.status_code == 200 + assert resp.json() == {"message": "Video status_vid status updated to read."} + + # Verify db was updated + async def verify(): + cursor = await test_db.execute("SELECT status FROM videos WHERE video_id = 'status_vid'") + row = await cursor.fetchone() + return row["status"] + + status = asyncio.run(verify()) + assert status == "read" + +def test_update_status_invalid_value(client): + resp = client.post("/api/videos/status_vid/status", json={"status": "archived"}) + assert resp.status_code == 400 + assert "Invalid status value" in resp.json()["detail"] + +def test_update_status_not_found(client): + resp = client.post("/api/videos/missing_vid/status", json={"status": "read"}) + assert resp.status_code == 404 + assert "Video not found" in resp.json()["detail"] + +def test_trigger_fetch(client): + with patch("main.bg_fetch_and_summarize") as mock_task: + resp = client.post("/api/videos/fetch") + assert resp.status_code == 200 + assert "sync and summarization running in background" in resp.json()["message"] + +@pytest.mark.asyncio +async def test_trigger_summarize(client, test_db): + # Insert video + await test_db.execute(""" + INSERT INTO videos (video_id, title, url, status) + VALUES ('sum_vid', 'Title', 'https://youtube.com/watch?v=sum_vid', 'pending') + """) + await test_db.commit() + + with patch("main.summarize_video") as mock_sum: + resp = client.post("/api/videos/sum_vid/summarize") + assert resp.status_code == 200 + assert "summarization for video sum_vid queued" in resp.json()["message"].lower() + +def test_trigger_summarize_not_found(client): + resp = client.post("/api/videos/missing_sum/summarize") + assert resp.status_code == 404 + assert "Video not found" in resp.json()["detail"] diff --git a/backend/tests/test_summarizer.py b/backend/tests/test_summarizer.py new file mode 100644 index 0000000..6cc3d2a --- /dev/null +++ b/backend/tests/test_summarizer.py @@ -0,0 +1,125 @@ +import pytest +import os +import httpx +import aiosqlite +import summarizer +from unittest.mock import patch, MagicMock, AsyncMock + +class MockTranscript: + def fetch(self): + return [ + {"text": "Welcome to this video tutorial.", "start": 0.0, "duration": 3.0}, + {"text": "Today we are learning about Svelte 5 and runes.", "start": 3.0, "duration": 4.0} + ] + +class MockTranscriptList: + def __init__(self, transcript): + self.transcript = transcript + + def find_manually_created_transcript(self, languages): + return self.transcript + + def find_generated_transcript(self, languages): + return self.transcript + + def __iter__(self): + return iter([self.transcript]) + +@pytest.mark.asyncio +async def test_download_transcript_success(): + mock_trans = MockTranscript() + mock_list = MockTranscriptList(mock_trans) + + with patch("youtube_transcript_api.YouTubeTranscriptApi.list_transcripts", return_value=mock_list): + text = summarizer.download_transcript("test_vid") + assert text == "Welcome to this video tutorial. Today we are learning about Svelte 5 and runes." + +@pytest.mark.asyncio +async def test_summarize_video_success(test_db): + # Insert a pending video in db first + await test_db.execute(""" + INSERT INTO videos (video_id, title, url, status) + VALUES ('summarize_vid', 'Video to Summarize', 'https://www.youtube.com/watch?v=summarize_vid', 'pending') + """) + await test_db.commit() + + mock_trans = MockTranscript() + mock_list = MockTranscriptList(mock_trans) + + # Mock Claude HTTP response + mock_response = MagicMock(spec=httpx.Response) + mock_response.status_code = 200 + mock_response.json.return_value = { + "content": [ + { + "text": "**Mock Core Hook**\n- Takeaway 1\n- Takeaway 2\nVerdict line." + } + ] + } + + # Patch both transcript api and httpx AsyncClient post call + with patch("youtube_transcript_api.YouTubeTranscriptApi.list_transcripts", return_value=mock_list), \ + patch("httpx.AsyncClient.post", new_callable=AsyncMock, return_value=mock_response) as mock_post: + + summary = await summarizer.summarize_video("summarize_vid") + + # Verify API request details + mock_post.assert_called_once() + url = mock_post.call_args[0][0] + assert "bytecatcode.org" in url + + headers = mock_post.call_args[1]["headers"] + assert "x-api-key" in headers + assert headers["User-Agent"] is not None + + payload = mock_post.call_args[1]["json"] + assert payload["model"] == "claude-haiku-4-5-20251001" + assert len(payload["messages"]) == 1 + + assert summary == "**Mock Core Hook**\n- Takeaway 1\n- Takeaway 2\nVerdict line." + + # Query db to verify transcript & summary were saved + cursor = await test_db.execute("SELECT * FROM videos WHERE video_id = 'summarize_vid'") + video = await cursor.fetchone() + assert video is not None + assert video["transcript"] == "Welcome to this video tutorial. Today we are learning about Svelte 5 and runes." + assert video["summary"] == "**Mock Core Hook**\n- Takeaway 1\n- Takeaway 2\nVerdict line." + +@pytest.mark.asyncio +async def test_summarize_all_pending(test_db): + # Insert multiple videos, one pending and one already summarized + await test_db.execute(""" + INSERT INTO videos (video_id, title, url, summary, status) + VALUES + ('pending_vid', 'Pending Video', 'https://youtube.com/watch?v=pending_vid', '', 'pending'), + ('done_vid', 'Done Video', 'https://youtube.com/watch?v=done_vid', 'Existing summary', 'pending') + """) + await test_db.commit() + + mock_trans = MockTranscript() + mock_list = MockTranscriptList(mock_trans) + + mock_response = MagicMock(spec=httpx.Response) + mock_response.status_code = 200 + mock_response.json.return_value = { + "content": [{"text": "Fresh summary"}] + } + + with patch("youtube_transcript_api.YouTubeTranscriptApi.list_transcripts", return_value=mock_list), \ + patch("httpx.AsyncClient.post", new_callable=AsyncMock, return_value=mock_response) as mock_post: + + await summarizer.summarize_all_pending() + + # Should only call API once for 'pending_vid' + mock_post.assert_called_once() + payload = mock_post.call_args[1]["json"] + assert "Welcome to this video tutorial." in payload["messages"][0]["content"] + + # Verify db states + cursor = await test_db.execute("SELECT video_id, summary FROM videos ORDER BY video_id") + videos = await cursor.fetchall() + assert videos[0]["video_id"] == "done_vid" + assert videos[0]["summary"] == "Existing summary" # Untouched + + assert videos[1]["video_id"] == "pending_vid" + assert videos[1]["summary"] == "Fresh summary" # Updated