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 <noreply@anthropic.com>
This commit is contained in:
@@ -4,3 +4,6 @@ yt-dlp>=2023.0.0
|
|||||||
youtube-transcript-api>=0.6.0
|
youtube-transcript-api>=0.6.0
|
||||||
aiosqlite>=0.18.0
|
aiosqlite>=0.18.0
|
||||||
httpx>=0.24.0
|
httpx>=0.24.0
|
||||||
|
pytest>=7.0.0
|
||||||
|
pytest-asyncio>=0.21.0
|
||||||
|
pytest-mock>=3.10.0
|
||||||
|
|||||||
Binary file not shown.
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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"]
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user