# Attachment Service 单元测试与模型修复 **日期**: 2026-02-04 **类型**: 测试覆盖补充 + 规范修复 **模块**: - `server/tests/unit/services/test_attachment_service.py`(单元测试) - `server/app/models/attachment.py`(模型修复) - `server/app/repositories/attachment_repository.py`(Repository 修复) **影响范围**: 测试层 + 模型层 + Repository 层 --- ## 📋 概述 为 `AttachmentService` 创建完整的单元测试,覆盖文件上传、附件管理、权限控制等核心功能。同时发现并修复了 Attachment 模型违反 **ADR 006 规范**的问题。 --- ## 🚀 主要变更 ### 1. 单元测试创建 **位置**: `server/tests/unit/services/test_attachment_service.py` **测试组织结构**: ```python TestAttachmentService (26 个测试) ├── upload_attachment 测试(7 个) │ ├── test_upload_attachment_document_success │ ├── test_upload_attachment_image_success │ ├── test_upload_attachment_user_not_found │ ├── test_upload_attachment_related_entity_not_found │ ├── test_upload_attachment_no_permission │ ├── test_upload_attachment_invalid_document_type │ ├── test_upload_attachment_invalid_image_type │ └── test_upload_attachment_file_size_exceeds_limit │ ├── get_attachment 测试(4 个) │ ├── test_get_attachment_success_public │ ├── test_get_attachment_success_with_permission │ ├── test_get_attachment_not_found │ └── test_get_attachment_no_permission │ ├── get_download_url 测试(2 个) │ ├── test_get_download_url_success │ └── test_get_download_url_no_permission │ ├── delete_attachment 测试(4 个) │ ├── test_delete_attachment_by_uploader │ ├── test_delete_attachment_by_owner │ ├── test_delete_attachment_not_found │ └── test_delete_attachment_no_permission │ ├── get_attachments_by_related 测试(6 个) │ ├── test_get_attachments_by_related_success │ ├── test_get_attachments_by_related_filter_by_purpose │ ├── test_get_attachments_by_related_filter_by_category │ ├── test_get_attachments_by_related_pagination │ └── test_get_attachments_by_related_no_permission │ └── get_attachment_by_purpose 测试(3 个) ├── test_get_attachment_by_purpose_success ├── test_get_attachment_by_purpose_not_found └── test_get_attachment_by_purpose_no_permission ``` **测试的核心功能**: - ✅ 文件上传与验证(类型、大小) - ✅ 多态关联验证(user/project/storyboard/character/scene) - ✅ 权限控制(viewer/editor/owner) - ✅ 文件查询与下载 - ✅ 附件删除与引用计数 - ✅ 分页与筛选 ### 2. Attachment 模型修复(规范违规) **文件**: `server/app/models/attachment.py` #### 问题:违反 ADR 006 规范 **发现**:Attachment 模型的时间戳字段未使用 `TIMESTAMPTZ`(带时区的时间戳),违反了 **ADR 006 强制规范**。 **规范要求**(`jointo-tech-stack/references/database.md`): > - [ ] 时间字段使用 `TIMESTAMPTZ` 类型(ADR 006 强制规范) #### 修复内容 ```python # ❌ 违规(修复前) created_at: datetime = Field( default_factory=lambda: datetime.now(timezone.utc) ) # ✅ 符合规范(修复后) from sqlalchemy import TIMESTAMP created_at: datetime = Field( default_factory=lambda: datetime.now(timezone.utc), sa_column=Column(TIMESTAMP(timezone=True), nullable=False), # ✅ TIMESTAMPTZ description="创建时间" ) ``` **修复的字段**: - `created_at`: TIMESTAMP → **TIMESTAMPTZ** - `updated_at`: TIMESTAMP → **TIMESTAMPTZ** - `deleted_at`: TIMESTAMP → **TIMESTAMPTZ** - `expires_at`: TIMESTAMP → **TIMESTAMPTZ** **影响**: - ⚠️ 需要创建数据库迁移文件修改现有 `attachments` 表结构 - ✅ 符合 `database.md` 规范和 ADR 006 ### 3. AttachmentRepository 修复 **文件**: `server/app/repositories/attachment_repository.py` #### 修复:Project 字段访问错误 **问题**: 使用了不存在的 `Project.project_id` 字段 ```python # ❌ 错误(修复前) result = await self.session.execute( select(Project.project_id).where( Project.project_id == related_id, Project.deleted_at.is_(None) ).limit(1) ) # ✅ 正确(修复后) result = await self.session.execute( select(Project.id).where( Project.id == related_id, Project.deleted_at.is_(None) ).limit(1) ) ``` **说明**: Project 模型的主键字段是 `id`,虽然有 `alias="project_id"` 用于序列化,但查询时必须使用 `id`。 --- ## 🔍 测试覆盖点 ### 文件上传测试 - ✅ 文档上传成功(PDF, DOCX, TXT, MD) - ✅ 图片上传成功(PNG, JPG, GIF, WEBP) - ✅ 文件类型验证(拒绝不支持的类型) - ✅ 文件大小验证(文档 100MB,图片 20MB) - ✅ 用户存在性验证 - ✅ 关联实体存在性验证 - ✅ 上传权限验证(editor 权限) ### 附件查询测试 - ✅ 公开附件访问(任何用户) - ✅ 私有附件访问(需要权限) - ✅ 附件不存在处理 - ✅ 无权限访问拦截 - ✅ 访问计数自动增加 ### 附件下载测试 - ✅ 获取预签名 URL(Mock FileStorageService) - ✅ 下载计数自动增加 - ✅ 权限验证(转发到 get_attachment) ### 附件删除测试 - ✅ 上传者删除权限 - ✅ 项目所有者删除权限 - ✅ 软删除实现 - ✅ 引用计数管理(Mock decrease_reference_count) - ✅ 无权限删除拦截 ### 列表查询测试 - ✅ 按关联实体查询 - ✅ 按用途筛选(avatar/cover/document/reference) - ✅ 按类别筛选(document/image) - ✅ 分页(page, page_size) - ✅ 权限验证(viewer 权限) ### Mock 策略 - ✅ FileStorageService 完全 mock(upload_file, get_presigned_url, get_by_checksum, decrease_reference_count) - ✅ UploadFile 使用 Mock 对象 - ✅ 数据库使用真实 Session(pytest fixture 管理) --- ## 🐛 修复的问题 ### 问题 1:Attachment 模型违反 ADR 006 **错误信息**: ``` sqlalchemy.exc.DBAPIError: invalid input for query argument $19: datetime.datetime(2026, 2, 5, 1, 50, 29,... (can't subtract offset-naive and offset-aware datetimes) ``` **根本原因**: - Attachment 模型时间戳字段未显式指定 `TIMESTAMPTZ` - 导致数据库列使用 `TIMESTAMP WITHOUT TIME ZONE` - 与其他模型(如 Project)的 `TIMESTAMPTZ` 不兼容 **修复方案**: 显式指定 `TIMESTAMP(timezone=True)` ### 问题 2:Project 字段访问错误 **错误信息**: ``` AttributeError: project_id ``` **根本原因**: - AttachmentRepository 使用 `Project.project_id` - 但 Project 模型的实际字段是 `id`(alias 仅用于序列化) **修复方案**: 使用 `Project.id` 进行查询 ### 问题 3:UUID 对象比较失败 **错误信息**: ``` AssertionError: assert UUID('019c...') == UUID('019c...') ``` **根本原因**: - SQLModel 对象在刷新后内部状态变化 - 直接比较 UUID 对象可能因为内部元数据不同而失败 **修复方案**: 使用 `str()` 转换后比较 --- ## 📊 测试结果 ```bash $ docker exec jointo-server-app pytest tests/unit/services/test_attachment_service.py -v ============================== 26 passed in 7.84s ============================== ``` **测试通过率**: 100% (26/26) ✅ **执行时间**: 7.84s **Linter 检查**: 无错误 ✅ --- ## 🎓 关键设计决策 ### 1. Mock FileStorageService **策略**: 完全 mock 文件存储逻辑 ```python with patch.object( service.file_storage, 'upload_file', new_callable=AsyncMock, return_value=mock_file_metadata ): attachment = await service.upload_attachment(...) ``` **理由**: - 单元测试不应依赖真实文件存储 - 提高测试速度和稳定性 - 隔离测试关注点(业务逻辑 vs 存储逻辑) ### 2. 辅助方法设计 **创建 `_create_attachment` 辅助方法**: ```python def _create_attachment( self, name, related_id, uploaded_by, category=AttachmentCategory.DOCUMENT, purpose=AttachmentPurpose.DOCUMENT, is_public=False, **kwargs ) -> Attachment: """创建测试附件对象""" ``` **优点**: - 减少重复代码 - 统一时间戳处理(符合 ADR 006) - 灵活覆盖默认值 ### 3. 多态关联测试 **覆盖的关联类型**: - ✅ `RelatedType.USER` - 用户附件 - ✅ `RelatedType.PROJECT` - 项目附件 - ⚠️ `RelatedType.STORYBOARD/CHARACTER/SCENE` - 暂未测试(模型未实现) **权限验证**: - ✅ viewer - 可查看 - ✅ editor - 可上传 - ✅ owner - 可删除 --- ## ⚠️ 重要提醒 ### 需要创建数据库迁移 Attachment 模型的时间戳字段类型已修改,需要创建迁移文件: ```bash cd server alembic revision -m "fix_attachment_timestamps_to_timestamptz" ``` **迁移内容**: ```sql -- 将 attachments 表的时间戳列改为 TIMESTAMPTZ ALTER TABLE attachments ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'UTC', ALTER COLUMN updated_at TYPE TIMESTAMPTZ USING updated_at AT TIME ZONE 'UTC', ALTER COLUMN deleted_at TYPE TIMESTAMPTZ USING deleted_at AT TIME ZONE 'UTC', ALTER COLUMN expires_at TYPE TIMESTAMPTZ USING expires_at AT TIME ZONE 'UTC'; -- 添加列注释 COMMENT ON COLUMN attachments.created_at IS '创建时间'; COMMENT ON COLUMN attachments.updated_at IS '更新时间'; COMMENT ON COLUMN attachments.deleted_at IS '软删除时间'; COMMENT ON COLUMN attachments.expires_at IS '过期时间'; ``` --- ## 📝 相关文档 - **测试规范**: [.claude/skills/jointo-tech-stack/references/testing.md](../../.claude/skills/jointo-tech-stack/references/testing.md) - **数据库规范**: [.claude/skills/jointo-tech-stack/references/database.md](../../.claude/skills/jointo-tech-stack/references/database.md) - **ADR 006**: 时间戳字段必须使用 TIMESTAMPTZ - **测试 README**: [tests/README.md](../../server/tests/README.md) --- ## 🚀 运行命令 ```bash # 运行所有单元测试 docker exec jointo-server-app pytest tests/unit/services/test_attachment_service.py -v # 运行特定测试 docker exec jointo-server-app pytest tests/unit/services/test_attachment_service.py::TestAttachmentService::test_upload_attachment_document_success -v # 快速模式(跳过详细输出) docker exec jointo-server-app pytest tests/unit/services/test_attachment_service.py --tb=no ``` --- ## 🎯 后续优化建议 1. **集成测试**: 添加 Attachment API 层集成测试(涉及真实文件上传,需额外配置) 2. **数据库迁移**: 创建 Alembic 迁移文件修改 attachments 表结构 3. **其他关联类型**: 实现 Storyboard/Character/Scene 模型后,补充对应的权限测试 4. **性能测试**: 测试大文件上传(接近限制大小)的性能表现 5. **去重测试**: 测试文件去重逻辑(相同 checksum 的文件引用计数增加) --- **维护者**: Jointo 开发团队 **最后更新**: 2026-02-04