import json from dataclasses import replace from fastapi.testclient import TestClient from sentence_api.main import create_app from sentence_api.config import Settings from sentence_api.oss import VolcanoOSSUploader from sentence_api.models import SentenceBoundary, SentenceBoundaryDocument from sentence_api.processing import VideoProcessor from sentence_api.repository import VideoRepository from sentence_api.store import BoundaryStore VIDEO_HASH = "a" * 64 def make_client(tmp_path): index_path = tmp_path / "boundaries.json" index_path.write_text( json.dumps({ "videos": { VIDEO_HASH: { "duration_ms": 9000, "algorithm_version": "test-v1", "sentences": [ {"index": 0, "start_ms": 0, "end_ms": 4000, "text": "One."}, {"index": 1, "start_ms": 4000, "end_ms": 9000, "text": "Two."}, ], } } }), encoding="utf-8", ) settings = replace( Settings.from_env(), data_dir=tmp_path / "data", legacy_boundaries_file=index_path, ) settings.ensure_directories() return TestClient( create_app( BoundaryStore(index_path), settings=settings, repository=VideoRepository(settings.database_path), ) ) def test_lookup_returns_boundaries(tmp_path): client = make_client(tmp_path) response = client.get(f"/api/v1/videos/{VIDEO_HASH}/sentence-boundaries") assert response.status_code == 200 assert response.json()["sentences"][1]["start_ms"] == 4000 def test_unknown_hash_returns_not_found(tmp_path): client = make_client(tmp_path) response = client.get(f"/api/v1/videos/{'b' * 64}/sentence-boundaries") assert response.status_code == 404 assert response.json()["detail"]["code"] == "SENTENCE_BOUNDARIES_NOT_FOUND" def test_invalid_hash_is_rejected(tmp_path): client = make_client(tmp_path) response = client.get("/api/v1/videos/not-a-sha256/sentence-boundaries") assert response.status_code == 422 def test_oss_video_url_is_used_for_catalog_and_content(tmp_path): index_path = tmp_path / "boundaries.json" index_path.write_text("{}", encoding="utf-8") settings = replace( Settings.from_env(), data_dir=tmp_path / "data", legacy_boundaries_file=index_path, ) settings.ensure_directories() repository = VideoRepository(settings.database_path) repository.upsert_upload( video_hash=VIDEO_HASH, title="OSS lesson", filename="lesson.mp4", stored_filename=f"{VIDEO_HASH}.mp4", content_type="video/mp4", size_bytes=1, language=None, ) remote_url = f"https://course-media.example.com/videos/{VIDEO_HASH}.mp4" repository.mark_oss_uploaded(VIDEO_HASH, remote_url) client = TestClient( create_app( BoundaryStore(index_path), settings=settings, repository=repository, ) ) catalog = client.get("/api/v1/videos") stream = client.get(f"/api/v1/videos/{VIDEO_HASH}/content", follow_redirects=False) assert catalog.status_code == 200 assert catalog.json()["videos"][0]["stream_url"] == remote_url assert stream.status_code == 307 assert stream.headers["location"] == remote_url def test_volcano_oss_object_key_and_public_url(tmp_path): settings = replace( Settings.from_env(), volcano_oss_access_key="ak", volcano_oss_secret_key="sk", volcano_oss_bucket="course-media", volcano_oss_endpoint="tos-cn-beijing.volces.com", volcano_oss_region="cn-beijing", volcano_oss_key_prefix="lessons/videos/", ) uploader = VolcanoOSSUploader(settings) key = uploader.object_key(VIDEO_HASH, f"{VIDEO_HASH}.mp4") assert settings.volcano_oss_enabled is True assert key == f"lessons/videos/{VIDEO_HASH}.mp4" assert uploader.public_url(key) == ( f"https://course-media.tos-cn-beijing.volces.com/" f"lessons/videos/{VIDEO_HASH}.mp4" ) def test_processor_marks_ready_only_after_oss_upload(tmp_path, monkeypatch): settings = replace(Settings.from_env(), data_dir=tmp_path / "data") settings.ensure_directories() media_path = settings.videos_dir / f"{VIDEO_HASH}.mp4" media_path.write_bytes(b"video") repository = VideoRepository(settings.database_path) repository.upsert_upload( video_hash=VIDEO_HASH, title="OSS lesson", filename="lesson.mp4", stored_filename=f"{VIDEO_HASH}.mp4", content_type="video/mp4", size_bytes=5, language=None, ) document = SentenceBoundaryDocument( video_hash=VIDEO_HASH, duration_ms=1000, algorithm_version="test", sentences=[SentenceBoundary(index=0, start_ms=0, end_ms=1000)], ) uploads = [] class EnabledUploader: enabled = True def upload(self, **kwargs): uploads.append(kwargs) return "https://media.example.com/lesson.mp4" class UnavailableTranscriber: available = False monkeypatch.setattr( "sentence_api.processing.make_entry", lambda *args, **kwargs: ( { "duration_ms": 1000, "sentences": [{"index": 0, "start_ms": 0, "end_ms": 1000}], }, {}, ), ) processor = VideoProcessor( settings, repository, UnavailableTranscriber(), EnabledUploader(), ) processor.process(VIDEO_HASH) video = repository.get_video(VIDEO_HASH) assert uploads[0]["stored_filename"] == f"{VIDEO_HASH}.mp4" assert video["status"] == "ready" assert video["remote_url"] == "https://media.example.com/lesson.mp4" def test_admin_can_split_sentence_and_reindexes_following_sentences(tmp_path): settings = replace(Settings.from_env(), data_dir=tmp_path / "data") settings.ensure_directories() repository = VideoRepository(settings.database_path) repository.upsert_upload( video_hash=VIDEO_HASH, title="Manual split lesson", filename="lesson.mp4", stored_filename=f"{VIDEO_HASH}.mp4", content_type="video/mp4", size_bytes=5, language="en", ) repository.save_processing_result( SentenceBoundaryDocument( video_hash=VIDEO_HASH, duration_ms=9000, algorithm_version="test", sentences=[ SentenceBoundary( index=0, start_ms=0, end_ms=4000, text="First part second part", language="en", reference_speech_duration_ms=3600, ), SentenceBoundary( index=1, start_ms=4000, end_ms=9000, text="Next sentence.", language="en", reference_speech_duration_ms=4600, ), ], ), None, ) class UnavailableTranscriber: available = False client = TestClient( create_app( BoundaryStore(tmp_path / "boundaries.json"), settings=settings, repository=repository, transcriber=UnavailableTranscriber(), ) ) response = client.put( f"/api/v1/admin/videos/{VIDEO_HASH}/sentences/0/split", json={"text": "First part\nsecond part", "language": "en"}, ) assert response.status_code == 200 replacements = response.json() assert [item["text"] for item in replacements] == ["First part", "second part"] assert replacements[0]["start_ms"] == 0 assert replacements[0]["end_ms"] == replacements[1]["start_ms"] assert replacements[1]["end_ms"] == 4000 document = repository.get_document(VIDEO_HASH) assert [item.text for item in document.sentences] == [ "First part", "second part", "Next sentence.", ] assert [item.index for item in document.sentences] == [0, 1, 2]