You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 

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 → 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 字段

# ❌ 错误(修复前)
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 '过期时间';

📝 相关文档


🚀 运行命令

# 运行所有单元测试
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