"""测试:渲染完成后 generated_videos 写表判断逻辑. 验证:project_id 为空时,只要有 generation_task_id 就应该写入 generated_videos。 """ from __future__ import annotations from datetime import datetime, timezone from unittest.mock import MagicMock, patch import pytest from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker from packages.adapters.sqlalchemy_impl.generation_task_repository import ( SQLAlchemyGenerationTaskRepository, ) from packages.adapters.sqlalchemy_impl.models import Base from packages.domain import GenerationTask, GenerationTaskStatus def _make_task(**kwargs) -> GenerationTask: defaults = dict( project_id="proj-1", asset_library_id="lib-1", created_by_user_id="user-1", status=GenerationTaskStatus.PENDING, progress=0.0, source_edit_plan_id="plan-1", ) defaults.update(kwargs) return GenerationTask(id="task-" + kwargs.get("project_id", "t1")[:8], **defaults) class TestCleanupStaleRunning: """复用已有的 repo 测试基础设施,验证写表判断逻辑。""" def _setup(self): engine = create_engine("sqlite:///:memory:") Base.metadata.create_all(engine) session = sessionmaker(bind=engine)() return SQLAlchemyGenerationTaskRepository(session), session def test_generation_task_without_project_id_can_be_created(self): """验证:project_id 为空的 generation_task 可以正常创建(模拟一键生成场景)。""" repo, session = self._setup() try: task = _make_task( project_id="", status=GenerationTaskStatus.COMPLETED, progress=100.0, result_count=1, source_edit_plan_id="plan-empty-proj", ) task.id = "task-empty-proj" created = repo.create(task) assert created.project_id == "" fetched = repo.get("task-empty-proj") assert fetched is not None assert fetched.project_id == "" assert fetched.status.value == "completed" assert fetched.progress == 100.0 finally: session.close() def test_generation_task_with_project_id_can_be_created(self): """验证:project_id 有值的 generation_task 正常(回归测试)。""" repo, session = self._setup() try: task = _make_task( project_id="proj-123", status=GenerationTaskStatus.COMPLETED, progress=100.0, result_count=1, ) task.id = "task-with-proj" created = repo.create(task) assert created.project_id == "proj-123" fetched = repo.get("task-with-proj") assert fetched is not None assert fetched.project_id == "proj-123" finally: session.close() class TestVideoCreationLogic: """验证写表判断逻辑(纯逻辑测试,不依赖 DB)。""" def test_should_create_video_with_generation_task_id_and_empty_project(self): """有 generation_task_id、无 project_id → 应该创建视频(修复点)。""" generation_task_id = "task-123" project_id = "" # 修复后:只要有 generation_task_id 就创建 assert bool(generation_task_id) is True # 之前的错误逻辑:if generation_task_id and project_id → False assert bool(generation_task_id and project_id) is False # 修复后的逻辑:if generation_task_id → True assert bool(generation_task_id) is True def test_should_create_video_with_both_ids(self): """有 generation_task_id、有 project_id → 应该创建视频(回归)。""" generation_task_id = "task-123" project_id = "proj-456" assert bool(generation_task_id) is True assert bool(generation_task_id and project_id) is True def test_should_not_create_video_without_generation_task_id(self): """无 generation_task_id → 不创建视频(边界)。""" generation_task_id = "" project_id = "proj-456" assert bool(generation_task_id) is False class TestVideoNameParameter: """验证成片库视频名称逻辑:用户设置标题时用标题,没设置时用默认命名。""" def test_name_from_user_title(self): """用户设置了标题 → 用标题作为视频名称。""" # 验证函数签名包含 name 参数 import inspect from video_processing.dedup_helpers import create_video_record_and_dedup sig = inspect.signature(create_video_record_and_dedup) assert "name" in sig.parameters, "create_video_record_and_dedup 应支持 name 参数" def test_generated_video_requires_name(self): """GeneratedVideo.create 要求 name 非空。""" from packages.domain import GeneratedVideo with pytest.raises(ValueError, match="name cannot be empty"): GeneratedVideo.create( project_id="proj-1", user_id="user-1", generation_task_id="task-1", name="", file_url="https://example.com/test.mp4", ) def test_name_fallback_when_empty(self): """name 为空或纯空格时,调用方应 fallback 到默认命名。""" # 模拟 _finalize_render_success 中的逻辑 title_text_empty = "" title_text_spaces = " " generation_task_id = "task-abcdef123456" # 空标题 → fallback video_name_1 = title_text_empty.strip() or f"generated-{generation_task_id[:8]}.mp4" assert video_name_1 == f"generated-{generation_task_id[:8]}.mp4" # 纯空格 → fallback video_name_2 = title_text_spaces.strip() or f"generated-{generation_task_id[:8]}.mp4" assert video_name_2 == f"generated-{generation_task_id[:8]}.mp4" # 有标题 → 用标题 title_text = "我的旅行vlog" video_name_3 = title_text.strip() or f"generated-{generation_task_id[:8]}.mp4" assert video_name_3 == "我的旅行vlog" class TestUserIdFilter: """验证成片库按 user_id 过滤的核心逻辑。""" def _setup_repo(self): from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker from packages.adapters.sqlalchemy_impl.generated_video_repository import ( SQLAlchemyGeneratedVideoRepository, ) from packages.adapters.sqlalchemy_impl.models import Base engine = create_engine("sqlite:///:memory:") Base.metadata.create_all(engine) session = sessionmaker(bind=engine)() return SQLAlchemyGeneratedVideoRepository(session), session def test_list_paginated_filters_by_user_id(self): """list_paginated 传入 user_id 时只返回该用户的视频。""" from packages.domain import GeneratedVideo repo, session = self._setup_repo() try: # 用户A的2个视频 for i in range(2): v = GeneratedVideo.create( project_id=f"proj-a-{i}", user_id="user-a", generation_task_id=f"task-a-{i}", name=f"video-a-{i}.mp4", file_url=f"https://oss.example.com/a-{i}.mp4", ) repo.create(v) # 用户B的3个视频 for i in range(3): v = GeneratedVideo.create( project_id=f"proj-b-{i}", user_id="user-b", generation_task_id=f"task-b-{i}", name=f"video-b-{i}.mp4", file_url=f"https://oss.example.com/b-{i}.mp4", ) repo.create(v) # 查用户A → 2条 items, total = repo.list_paginated(user_id="user-a", page=1, page_size=10) assert total == 2 assert len(items) == 2 assert all(it.user_id == "user-a" for it in items) # 查用户B → 3条 items, total = repo.list_paginated(user_id="user-b", page=1, page_size=10) assert total == 3 assert len(items) == 3 assert all(it.user_id == "user-b" for it in items) finally: session.close() def test_list_paginated_user_id_plus_project_id(self): """同时传 user_id 和 project_id 时两个条件同时过滤。""" from packages.domain import GeneratedVideo repo, session = self._setup_repo() try: # 用户A的proj-1视频 v1 = GeneratedVideo.create( project_id="proj-1", user_id="user-a", generation_task_id="task-1", name="v1.mp4", file_url="https://oss.example.com/v1.mp4", ) repo.create(v1) # 用户B的proj-1视频(不同用户同项目) v2 = GeneratedVideo.create( project_id="proj-1", user_id="user-b", generation_task_id="task-2", name="v2.mp4", file_url="https://oss.example.com/v2.mp4", ) repo.create(v2) # 用户A + proj-1 → 只有1条 items, total = repo.list_paginated(user_id="user-a", project_id="proj-1", page=1, page_size=10) assert total == 1 assert items[0].user_id == "user-a" assert items[0].generation_task_id == "task-1" finally: session.close() def test_generated_video_has_user_id_field(self): """GeneratedVideo domain 对象有 user_id 字段。""" from packages.domain import GeneratedVideo v = GeneratedVideo.create( project_id="proj-1", user_id="user-123", generation_task_id="task-1", name="test.mp4", file_url="https://example.com/test.mp4", ) assert v.user_id == "user-123" def test_created_video_persists_user_id(self): """创建视频后 user_id 能正确持久化和读取。""" from packages.domain import GeneratedVideo repo, session = self._setup_repo() try: v = GeneratedVideo.create( project_id="proj-1", user_id="user-persist-test", generation_task_id="task-persist", name="persist.mp4", file_url="https://example.com/persist.mp4", ) repo.create(v) fetched = repo.get(v.id) assert fetched is not None assert fetched.user_id == "user-persist-test" finally: session.close() class TestThumbnailInDedupHelpers: """验证 dedup_helpers 中缩略图相关逻辑(全 mock,不依赖 cv2)。""" @classmethod def setup_class(cls): """用 mock 模块替代需要 cv2 的 dedup 模块,避免导入失败。 注意:光往 sys.modules 塞不够,patch() 走属性访问链, 必须给 video_processing 包设置对应子模块属性。 """ import sys from unittest.mock import MagicMock # 先 mock 掉 cv2 if "cv2" not in sys.modules: sys.modules["cv2"] = MagicMock() # mock video_processing.dedup mock_dedup = MagicMock() mock_dedup.VideoDeduplicator = MagicMock() sys.modules["video_processing.dedup"] = mock_dedup # mock video_processing.thumbnail_generator (统一封面管道: 仅保留 extract_first_frame) mock_thumb = MagicMock() mock_thumb.extract_first_frame = MagicMock() sys.modules["video_processing.thumbnail_generator"] = mock_thumb # 关键:给 video_processing 包设置子模块属性,让 patch() 能通过属性访问找到 import video_processing video_processing.dedup = mock_dedup video_processing.thumbnail_generator = mock_thumb @classmethod def teardown_class(cls): """恢复 setup_class 中替换的模块,避免污染后续测试。""" import sys import video_processing # 从 sys.modules 移除 mock 模块 for mod_name in ("video_processing.thumbnail_generator", "video_processing.dedup"): sys.modules.pop(mod_name, None) # 重新导入真实模块以恢复 sys.modules try: import video_processing.thumbnail_generator # noqa: F401 except Exception: pass try: import video_processing.dedup # noqa: F401 except Exception: pass def test_pre_generated_thumbnail_url_is_reused(self): """传入 thumbnail_url 时直接复用,统一封面管道不再自动生成缩略图。""" from video_processing.dedup_helpers import create_video_record_and_dedup engine = create_engine("sqlite:///:memory:") Base.metadata.create_all(engine) Session = sessionmaker(bind=engine) session = Session() pre_thumb_url = "https://oss.example.com/pre-thumb.jpg" try: with patch("video_processing.dedup.VideoDeduplicator") as mock_dedup_cls: mock_dedup = mock_dedup_cls.return_value mock_dedup.compute_fingerprint.return_value = MagicMock(to_dict=lambda: {}) mock_dedup.check_duplicate.return_value = None mock_dedup.check_batch_duplicate.return_value = None mock_dedup.compute_duplicate_rate.return_value = { "duplicate_rate": 0.0, "visual_similarity": 0.0, "match_count": 0, } result = create_video_record_and_dedup( generation_task_id="task-thumb-reuse", project_id="proj-1", batch_id="", file_url="https://oss.example.com/video.mp4", file_size=1024, duration=10.0, video_path="/tmp/fake.mp4", mode="edit_plan", session=session, width=1280, height=720, fps=25.0, thumbnail_url=pre_thumb_url, ) # #1743:返回 dict(非批次 batch_similarity=None) assert result["video_count"] == 1 from packages.adapters.sqlalchemy_impl.models import GeneratedVideoModel video = session.query(GeneratedVideoModel).filter_by(generation_task_id="task-thumb-reuse").first() assert video is not None assert video.thumbnail_url == pre_thumb_url finally: session.close() def test_no_thumbnail_when_not_provided(self): """未传 thumbnail_url 时不生成缩略图(统一封面管道已移除自动缩略图生成)。""" from video_processing.dedup_helpers import create_video_record_and_dedup engine = create_engine("sqlite:///:memory:") Base.metadata.create_all(engine) Session = sessionmaker(bind=engine) session = Session() try: with patch("video_processing.dedup.VideoDeduplicator") as mock_dedup_cls: mock_dedup = mock_dedup_cls.return_value mock_dedup.compute_fingerprint.return_value = MagicMock(to_dict=lambda: {}) mock_dedup.check_duplicate.return_value = None mock_dedup.check_batch_duplicate.return_value = None mock_dedup.compute_duplicate_rate.return_value = { "duplicate_rate": 0.0, "visual_similarity": 0.0, "match_count": 0, } result = create_video_record_and_dedup( generation_task_id="task-thumb-gen", project_id="proj-1", batch_id="", file_url="https://oss.example.com/video.mp4", file_size=1024, duration=10.0, video_path="/tmp/fake.mp4", mode="edit_plan", session=session, width=1280, height=720, fps=25.0, ) # #1743:返回 dict(非批次 batch_similarity=None) assert result["video_count"] == 1 from packages.adapters.sqlalchemy_impl.models import GeneratedVideoModel video = session.query(GeneratedVideoModel).filter_by(generation_task_id="task-thumb-gen").first() assert video is not None # 统一封面管道下,不传 thumbnail_url 时不自动生成 assert not video.thumbnail_url finally: session.close() def test_no_thumbnail_does_not_block(self): """统一封面管道下,缩略图不再在 dedup 阶段生成。""" from video_processing.dedup_helpers import create_video_record_and_dedup engine = create_engine("sqlite:///:memory:") Base.metadata.create_all(engine) Session = sessionmaker(bind=engine) session = Session() try: with patch("video_processing.dedup.VideoDeduplicator") as mock_dedup_cls: mock_dedup = mock_dedup_cls.return_value mock_dedup.compute_fingerprint.return_value = MagicMock(to_dict=lambda: {}) mock_dedup.check_duplicate.return_value = None mock_dedup.check_batch_duplicate.return_value = None mock_dedup.compute_duplicate_rate.return_value = { "duplicate_rate": 0.0, "visual_similarity": 0.0, "match_count": 0, } result = create_video_record_and_dedup( generation_task_id="task-thumb-fail", project_id="proj-1", batch_id="", file_url="https://oss.example.com/video.mp4", file_size=1024, duration=10.0, video_path="/tmp/fake.mp4", mode="edit_plan", session=session, width=1280, height=720, fps=25.0, ) assert result["video_count"] == 1 # 不阻断(#1743 dict 返回) from packages.adapters.sqlalchemy_impl.models import GeneratedVideoModel video = session.query(GeneratedVideoModel).filter_by(generation_task_id="task-thumb-fail").first() assert video is not None # 缩略图生成失败时 thumbnail_url 为 None 或空串 assert not video.thumbnail_url finally: session.close()