11 KiB
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
测试组织结构:
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 强制规范)
修复内容
# ❌ 违规(修复前)
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 → TIMESTAMPTZupdated_at: TIMESTAMP → TIMESTAMPTZdeleted_at: TIMESTAMP → TIMESTAMPTZexpires_at: TIMESTAMP → TIMESTAMPTZ
影响:
- ⚠️ 需要创建数据库迁移文件修改现有
attachments表结构 - ✅ 符合
database.md规范和 ADR 006
3. AttachmentRepository 修复
文件: server/app/repositories/attachment_repository.py
修复:Project 字段访问错误
问题: 使用了不存在的 Project.project_id 字段
# ❌ 错误(修复前)
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() 转换后比较
📊 测试结果
$ 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 文件存储逻辑
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 辅助方法:
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 模型的时间戳字段类型已修改,需要创建迁移文件:
cd server
alembic revision -m "fix_attachment_timestamps_to_timestamptz"
迁移内容:
-- 将 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/database.md
- ADR 006: 时间戳字段必须使用 TIMESTAMPTZ
- 测试 README: tests/README.md
🚀 运行命令
# 运行所有单元测试
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
🎯 后续优化建议
- 集成测试: 添加 Attachment API 层集成测试(涉及真实文件上传,需额外配置)
- 数据库迁移: 创建 Alembic 迁移文件修改 attachments 表结构
- 其他关联类型: 实现 Storyboard/Character/Scene 模型后,补充对应的权限测试
- 性能测试: 测试大文件上传(接近限制大小)的性能表现
- 去重测试: 测试文件去重逻辑(相同 checksum 的文件引用计数增加)
维护者: Jointo 开发团队
最后更新: 2026-02-04