Compare commits

..

2 Commits

Author SHA1 Message Date
CI Bot 13a1070fbc style: auto-format with black + isort + prettier [skip ci-format-check]
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 14s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m23s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m33s
AI Code Review / AI Code Review (pull_request) Successful in 1m24s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m4s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 1m25s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m40s
CI/CD Pipeline / Validate - Code Quality (pull_request) Successful in 5m29s
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 3m28s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 3m23s
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Successful in 4m19s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Successful in 3m17s
CI/CD Pipeline / CI Gate (pull_request) Successful in 23s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Has been cancelled
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 1m0s
2026-07-29 14:02:11 +00:00
xiaoxia 7f7a66899b test(domain): add wave196 small modules unit tests (+74)
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 15s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
AI Code Review / AI Code Review (pull_request) Successful in 1m16s
CI/CD Pipeline / Validate - Type Check (mypy) (pull_request) Successful in 1m40s
CI/CD Pipeline / Validate - Migration (alembic) (pull_request) Successful in 1m37s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 1m5s
CI/CD Pipeline / Validate - Code Quality (pull_request) Has been cancelled
CI/CD Pipeline / Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Lint (pull_request) Has been cancelled
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been cancelled
CI/CD Pipeline / PR Build API Image (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Web Image (pull_request) Has been cancelled
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been cancelled
CI/CD Pipeline / Build Production API Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Web Image (pull_request) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been cancelled
CI/CD Pipeline / Deploy Production (pull_request) Has been cancelled
CI/CD Pipeline / Production Browser E2E (pull_request) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been cancelled
CI/CD Pipeline / Canary Release to Production (pull_request) Has been cancelled
CI/CD Pipeline / CI Gate (pull_request) Has been cancelled
PR Automation / Auto Approve on CI Green (pull_request) Has been cancelled
Preview Deploy / Deploy Preview Environment (pull_request) Has been cancelled
Batch add unit tests for 5 small domain modules:
- bgm_utils: merge_bgm_config (26 tests)
- exceptions: DomainError/NotFoundError/ValidationError/QuotaExceededError (18 tests)
- template_version: EditTemplateVersion create/construction/slots/equality (12 tests)
- email_config: EmailConfig dataclass (6 tests)
- auth ports: 6 ABC interfaces (SessionStore/Email/JWT/PasswordHasher/PasswordValidator/SmsService) (12 tests)
2026-07-29 21:56:17 +08:00
15 changed files with 1051 additions and 472 deletions
@@ -1,4 +0,0 @@
export { useBatchDelete } from "./useBatchDelete"
export { useBatchTag } from "./useBatchTag"
export { useBatchClassify } from "./useBatchClassify"
export { useBatchMark } from "./useBatchMark"
@@ -1,58 +0,0 @@
import { useState, useCallback } from "react"
import { message } from "antd"
import { batchClassifyAssets, type BatchOperationResult } from "@/api/assets"
interface UseBatchClassifyOptions {
selectedIds: Set<string>
queryClient: ReturnType<typeof import("@tanstack/react-query").useQueryClient>
showResult: (result: BatchOperationResult, title: string, clear?: boolean) => void
}
export const useBatchClassify = ({
selectedIds,
queryClient,
showResult,
}: UseBatchClassifyOptions) => {
const [classifyModalOpen, setClassifyModalOpen] = useState(false)
const [batchCategory, setBatchCategory] = useState("")
const [batchLoading, setBatchLoading] = useState(false)
const handleBatchClassify = useCallback(async () => {
if (!batchCategory) {
message.warning("请选择分类")
return
}
const ids = Array.from(selectedIds)
setBatchLoading(true)
try {
const result = await batchClassifyAssets({
asset_ids: ids,
category: batchCategory,
})
queryClient.invalidateQueries({ queryKey: ["assets"] })
showResult(result, "批量改分类")
setClassifyModalOpen(false)
setBatchCategory("")
if (result.failure_count === 0) {
message.success(`成功将 ${result.success_count} 个素材改为「${batchCategory}`)
} else {
message.warning(
`改分类完成:成功 ${result.success_count} 个,失败 ${result.failure_count}`,
)
}
} catch {
message.error("批量改分类失败,请重试")
} finally {
setBatchLoading(false)
}
}, [batchCategory, selectedIds, queryClient, showResult])
return {
classifyModalOpen,
setClassifyModalOpen,
batchCategory,
setBatchCategory,
batchLoading,
handleBatchClassify,
}
}
@@ -1,40 +0,0 @@
import { useState, useCallback } from "react"
import { message } from "antd"
import { batchDeleteAssets, type BatchOperationResult } from "@/api/assets"
interface UseBatchDeleteOptions {
selectedIds: Set<string>
invalidateAssets: () => void
showResult: (result: BatchOperationResult, title: string, clear?: boolean) => void
}
export const useBatchDelete = ({
selectedIds,
invalidateAssets,
showResult,
}: UseBatchDeleteOptions) => {
const [batchLoading, setBatchLoading] = useState(false)
const handleBatchDelete = useCallback(async () => {
const ids = Array.from(selectedIds)
setBatchLoading(true)
try {
const result = await batchDeleteAssets(ids)
invalidateAssets()
showResult(result, "批量删除")
if (result.failure_count === 0) {
message.success(`成功删除 ${result.success_count} 个素材`)
} else {
message.warning(
`删除完成:成功 ${result.success_count} 个,失败 ${result.failure_count}`,
)
}
} catch {
message.error("批量删除失败,请重试")
} finally {
setBatchLoading(false)
}
}, [selectedIds, invalidateAssets, showResult])
return { batchLoading, handleBatchDelete }
}
@@ -1,53 +0,0 @@
import { useState, useCallback } from "react"
import { message } from "antd"
import { batchMarkAssets, type BatchOperationResult } from "@/api/assets"
import type { SmartViewType } from "../../../components/BatchMarkModal"
import { SMART_VIEW_LABELS } from "../constants"
interface UseBatchMarkOptions {
selectedIds: Set<string>
queryClient: ReturnType<typeof import("@tanstack/react-query").useQueryClient>
showResult: (result: BatchOperationResult, title: string, clear?: boolean) => void
}
export const useBatchMark = ({ selectedIds, queryClient, showResult }: UseBatchMarkOptions) => {
const [markModalOpen, setMarkModalOpen] = useState(false)
const [batchSmartView, setBatchSmartView] = useState<SmartViewType>("recommended")
const [batchLoading, setBatchLoading] = useState(false)
const handleBatchMark = useCallback(async () => {
const ids = Array.from(selectedIds)
setBatchLoading(true)
try {
const result = await batchMarkAssets({
asset_ids: ids,
smart_view: batchSmartView,
})
queryClient.invalidateQueries({ queryKey: ["assets"] })
showResult(result, "批量智能标记")
setMarkModalOpen(false)
if (result.failure_count === 0) {
message.success(
`成功将 ${result.success_count} 个素材标记为「${SMART_VIEW_LABELS[batchSmartView]}`,
)
} else {
message.warning(
`智能标记完成:成功 ${result.success_count} 个,失败 ${result.failure_count}`,
)
}
} catch {
message.error("批量智能标记失败,请重试")
} finally {
setBatchLoading(false)
}
}, [batchSmartView, selectedIds, queryClient, showResult])
return {
markModalOpen,
setMarkModalOpen,
batchSmartView,
setBatchSmartView,
batchLoading,
handleBatchMark,
}
}
@@ -1,86 +0,0 @@
import { useState, useCallback } from "react"
import { message } from "antd"
import { batchTagAssets, type BatchOperationResult } from "@/api/assets"
interface UseBatchTagOptions {
selectedIds: Set<string>
queryClient: ReturnType<typeof import("@tanstack/react-query").useQueryClient>
showResult: (result: BatchOperationResult, title: string, clear?: boolean) => void
}
export const useBatchTag = ({ selectedIds, queryClient, showResult }: UseBatchTagOptions) => {
const [tagModalOpen, setTagModalOpen] = useState(false)
const [batchTagInput, setBatchTagInput] = useState("")
const [batchTags, setBatchTags] = useState<string[]>([])
const [tagMode, setTagMode] = useState<"add" | "replace">("add")
const [batchLoading, setBatchLoading] = useState(false)
const handleBatchTag = useCallback(async () => {
if (batchTags.length === 0) {
message.warning("请至少输入一个标签")
return
}
const ids = Array.from(selectedIds)
setBatchLoading(true)
try {
const result = await batchTagAssets({
asset_ids: ids,
tags: batchTags,
mode: tagMode,
})
queryClient.invalidateQueries({ queryKey: ["assets"] })
showResult(result, "批量打标签")
setTagModalOpen(false)
setBatchTags([])
setBatchTagInput("")
setTagMode("add")
if (result.failure_count === 0) {
message.success(`成功为 ${result.success_count} 个素材打标签`)
} else {
message.warning(
`打标签完成:成功 ${result.success_count} 个,失败 ${result.failure_count}`,
)
}
} catch {
message.error("批量打标签失败,请重试")
} finally {
setBatchLoading(false)
}
}, [batchTags, selectedIds, tagMode, queryClient, showResult])
const handleTagInputKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Enter" && batchTagInput.trim()) {
e.preventDefault()
const tag = batchTagInput.trim()
if (!batchTags.includes(tag)) {
setBatchTags([...batchTags, tag])
}
setBatchTagInput("")
}
},
[batchTagInput, batchTags],
)
const removeBatchTag = useCallback(
(tag: string) => {
setBatchTags(batchTags.filter((t) => t !== tag))
},
[batchTags],
)
return {
tagModalOpen,
setTagModalOpen,
batchTagInput,
setBatchTagInput,
batchTags,
setBatchTags,
tagMode,
setTagMode,
batchLoading,
handleBatchTag,
handleTagInputKeyDown,
removeBatchTag,
}
}
@@ -0,0 +1,238 @@
import { useState, useCallback } from "react"
import { message } from "antd"
import {
batchDeleteAssets,
batchTagAssets,
batchClassifyAssets,
batchMarkAssets,
type BatchOperationResult,
} from "@/api/assets"
import type { SmartViewType } from "../../components/BatchMarkModal"
import { SMART_VIEW_LABELS } from "./constants"
/* ── 批量删除 ── */
interface UseBatchDeleteOptions {
selectedIds: Set<string>
invalidateAssets: () => void
showResult: (result: BatchOperationResult, title: string, clear?: boolean) => void
}
export const useBatchDelete = ({
selectedIds,
invalidateAssets,
showResult,
}: UseBatchDeleteOptions) => {
const [batchLoading, setBatchLoading] = useState(false)
const handleBatchDelete = useCallback(async () => {
const ids = Array.from(selectedIds)
setBatchLoading(true)
try {
const result = await batchDeleteAssets(ids)
invalidateAssets()
showResult(result, "批量删除")
if (result.failure_count === 0) {
message.success(`成功删除 ${result.success_count} 个素材`)
} else {
message.warning(
`删除完成:成功 ${result.success_count} 个,失败 ${result.failure_count}`,
)
}
} catch {
message.error("批量删除失败,请重试")
} finally {
setBatchLoading(false)
}
}, [selectedIds, invalidateAssets, showResult])
return { batchLoading, handleBatchDelete }
}
/* ── 批量打标签 ── */
interface UseBatchTagOptions {
selectedIds: Set<string>
queryClient: ReturnType<typeof import("@tanstack/react-query").useQueryClient>
showResult: (result: BatchOperationResult, title: string, clear?: boolean) => void
}
export const useBatchTag = ({ selectedIds, queryClient, showResult }: UseBatchTagOptions) => {
const [tagModalOpen, setTagModalOpen] = useState(false)
const [batchTagInput, setBatchTagInput] = useState("")
const [batchTags, setBatchTags] = useState<string[]>([])
const [tagMode, setTagMode] = useState<"add" | "replace">("add")
const [batchLoading, setBatchLoading] = useState(false)
const handleBatchTag = useCallback(async () => {
if (batchTags.length === 0) {
message.warning("请至少输入一个标签")
return
}
const ids = Array.from(selectedIds)
setBatchLoading(true)
try {
const result = await batchTagAssets({
asset_ids: ids,
tags: batchTags,
mode: tagMode,
})
queryClient.invalidateQueries({ queryKey: ["assets"] })
showResult(result, "批量打标签")
setTagModalOpen(false)
setBatchTags([])
setBatchTagInput("")
setTagMode("add")
if (result.failure_count === 0) {
message.success(`成功为 ${result.success_count} 个素材打标签`)
} else {
message.warning(
`打标签完成:成功 ${result.success_count} 个,失败 ${result.failure_count}`,
)
}
} catch {
message.error("批量打标签失败,请重试")
} finally {
setBatchLoading(false)
}
}, [batchTags, selectedIds, tagMode, queryClient, showResult])
const handleTagInputKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Enter" && batchTagInput.trim()) {
e.preventDefault()
const tag = batchTagInput.trim()
if (!batchTags.includes(tag)) {
setBatchTags([...batchTags, tag])
}
setBatchTagInput("")
}
},
[batchTagInput, batchTags],
)
const removeBatchTag = useCallback(
(tag: string) => {
setBatchTags(batchTags.filter((t) => t !== tag))
},
[batchTags],
)
return {
tagModalOpen,
setTagModalOpen,
batchTagInput,
setBatchTagInput,
batchTags,
setBatchTags,
tagMode,
setTagMode,
batchLoading,
handleBatchTag,
handleTagInputKeyDown,
removeBatchTag,
}
}
/* ── 批量改分类 ── */
interface UseBatchClassifyOptions {
selectedIds: Set<string>
queryClient: ReturnType<typeof import("@tanstack/react-query").useQueryClient>
showResult: (result: BatchOperationResult, title: string, clear?: boolean) => void
}
export const useBatchClassify = ({
selectedIds,
queryClient,
showResult,
}: UseBatchClassifyOptions) => {
const [classifyModalOpen, setClassifyModalOpen] = useState(false)
const [batchCategory, setBatchCategory] = useState("")
const [batchLoading, setBatchLoading] = useState(false)
const handleBatchClassify = useCallback(async () => {
if (!batchCategory) {
message.warning("请选择分类")
return
}
const ids = Array.from(selectedIds)
setBatchLoading(true)
try {
const result = await batchClassifyAssets({
asset_ids: ids,
category: batchCategory,
})
queryClient.invalidateQueries({ queryKey: ["assets"] })
showResult(result, "批量改分类")
setClassifyModalOpen(false)
setBatchCategory("")
if (result.failure_count === 0) {
message.success(`成功将 ${result.success_count} 个素材改为「${batchCategory}`)
} else {
message.warning(
`改分类完成:成功 ${result.success_count} 个,失败 ${result.failure_count}`,
)
}
} catch {
message.error("批量改分类失败,请重试")
} finally {
setBatchLoading(false)
}
}, [batchCategory, selectedIds, queryClient, showResult])
return {
classifyModalOpen,
setClassifyModalOpen,
batchCategory,
setBatchCategory,
batchLoading,
handleBatchClassify,
}
}
/* ── 批量智能标记 ── */
interface UseBatchMarkOptions {
selectedIds: Set<string>
queryClient: ReturnType<typeof import("@tanstack/react-query").useQueryClient>
showResult: (result: BatchOperationResult, title: string, clear?: boolean) => void
}
export const useBatchMark = ({ selectedIds, queryClient, showResult }: UseBatchMarkOptions) => {
const [markModalOpen, setMarkModalOpen] = useState(false)
const [batchSmartView, setBatchSmartView] = useState<SmartViewType>("recommended")
const [batchLoading, setBatchLoading] = useState(false)
const handleBatchMark = useCallback(async () => {
const ids = Array.from(selectedIds)
setBatchLoading(true)
try {
const result = await batchMarkAssets({
asset_ids: ids,
smart_view: batchSmartView,
})
queryClient.invalidateQueries({ queryKey: ["assets"] })
showResult(result, "批量智能标记")
setMarkModalOpen(false)
if (result.failure_count === 0) {
message.success(
`成功将 ${result.success_count} 个素材标记为「${SMART_VIEW_LABELS[batchSmartView]}`,
)
} else {
message.warning(
`智能标记完成:成功 ${result.success_count} 个,失败 ${result.failure_count}`,
)
}
} catch {
message.error("批量智能标记失败,请重试")
} finally {
setBatchLoading(false)
}
}, [batchSmartView, selectedIds, queryClient, showResult])
return {
markModalOpen,
setMarkModalOpen,
batchSmartView,
setBatchSmartView,
batchLoading,
handleBatchMark,
}
}
@@ -12,7 +12,7 @@ import {
useBatchTag,
useBatchClassify,
useBatchMark,
} from "./asset-operations/batch-operations"
} from "./asset-operations/batchOperations"
import type { BatchOperationResult } from "@/api/assets"
import type { SmartViewType } from "../components/BatchMarkModal"
@@ -36,11 +36,6 @@ import "@/pages/assets/hooks/useLibraryManagement"
import "@/pages/assets/hooks/useAssetUpload"
import "@/pages/assets/hooks/useAssetSelection"
import "@/pages/assets/hooks/useAssetOperations"
import "@/pages/assets/hooks/asset-operations/batch-operations"
import "@/pages/assets/hooks/asset-operations/batch-operations/useBatchDelete"
import "@/pages/assets/hooks/asset-operations/batch-operations/useBatchTag"
import "@/pages/assets/hooks/asset-operations/batch-operations/useBatchClassify"
import "@/pages/assets/hooks/asset-operations/batch-operations/useBatchMark"
describe("AssetLibrary module smoke test", () => {
it("should load all asset modules", () => {
+1 -1
View File
@@ -301,7 +301,7 @@ def main():
pr_num = pr["number"]
pr_title = pr["title"]
head_sha = pr["head"]["sha"]
base_ref = pr.get("base", {}).get("ref", "")
base_ref = pr.get("base", {}).get("re", "")
# 跳过draft
if pr.get("draft"):
+159
View File
@@ -0,0 +1,159 @@
"""Auth ports (ABC接口) 单元测试.
验证抽象接口定义正确:不能直接实例化,子类必须实现所有抽象方法。
"""
from __future__ import annotations
from abc import ABC
import pytest
from domain.auth.email_service import EmailServicePort
from domain.auth.jwt_service import JWTServicePort
from domain.auth.password_hasher import PasswordHasherPort, PasswordValidatorPort
from domain.auth.session_store import SessionStorePort
from domain.auth.sms_service import SmsService
class TestSessionStorePort:
"""SessionStorePort 接口测试."""
def test_is_abstract(self):
assert issubclass(SessionStorePort, ABC)
def test_cannot_instantiate(self):
with pytest.raises(TypeError):
SessionStorePort() # type: ignore[misc]
def test_has_abstract_methods(self):
abstract_methods = SessionStorePort.__abstractmethods__
expected = {
"save_session",
"get_session",
"get_session_by_refresh_token",
"get_refresh_token",
"update_last_active",
"delete_session",
"get_user_sessions",
"delete_all_user_sessions",
"session_exists",
}
assert expected.issubset(abstract_methods)
def test_concrete_subclass_works(self):
class ConcreteStore(SessionStorePort):
def save_session(self, **kwargs): # type: ignore[override]
return True
def get_session(self, session_id): # type: ignore[override]
return None
def get_session_by_refresh_token(self, token): # type: ignore[override]
return None
def get_refresh_token(self, session_id): # type: ignore[override]
return None
def update_last_active(self, session_id): # type: ignore[override]
return True
def delete_session(self, session_id): # type: ignore[override]
return True
def get_user_sessions(self, user_id): # type: ignore[override]
return []
def delete_all_user_sessions(self, user_id): # type: ignore[override]
return 0
def session_exists(self, session_id): # type: ignore[override]
return False
store = ConcreteStore()
assert isinstance(store, SessionStorePort)
assert store.session_exists("s1") is False
assert store.delete_session("s1") is True
class TestEmailServicePort:
"""EmailServicePort 接口测试."""
def test_is_abstract(self):
assert issubclass(EmailServicePort, ABC)
def test_cannot_instantiate(self):
with pytest.raises(TypeError):
EmailServicePort() # type: ignore[misc]
def test_has_abstract_methods(self):
abstract_methods = EmailServicePort.__abstractmethods__
expected = {"send_email", "send_verification_email", "send_password_reset_email"}
assert expected.issubset(abstract_methods)
class TestJWTServicePort:
"""JWTServicePort 接口测试."""
def test_is_abstract(self):
assert issubclass(JWTServicePort, ABC)
def test_cannot_instantiate(self):
with pytest.raises(TypeError):
JWTServicePort() # type: ignore[misc]
def test_has_abstract_methods(self):
abstract_methods = JWTServicePort.__abstractmethods__
expected = {
"create_access_token",
"create_refresh_token",
"verify_token",
"verify_access_token",
"verify_refresh_token",
}
assert expected.issubset(abstract_methods)
class TestPasswordHasherPort:
"""PasswordHasherPort 接口测试."""
def test_is_abstract(self):
assert issubclass(PasswordHasherPort, ABC)
def test_cannot_instantiate(self):
with pytest.raises(TypeError):
PasswordHasherPort() # type: ignore[misc]
def test_has_abstract_methods(self):
abstract_methods = PasswordHasherPort.__abstractmethods__
expected = {"hash_password", "verify_password", "needs_rehash"}
assert expected.issubset(abstract_methods)
class TestPasswordValidatorPort:
"""PasswordValidatorPort 接口测试."""
def test_is_abstract(self):
assert issubclass(PasswordValidatorPort, ABC)
def test_cannot_instantiate(self):
with pytest.raises(TypeError):
PasswordValidatorPort() # type: ignore[misc]
def test_has_validate_method(self):
assert "validate" in PasswordValidatorPort.__abstractmethods__
class TestSmsService:
"""SmsService 接口测试."""
def test_is_abstract(self):
assert issubclass(SmsService, ABC)
def test_cannot_instantiate(self):
with pytest.raises(TypeError):
SmsService() # type: ignore[misc]
def test_has_abstract_methods(self):
abstract_methods = SmsService.__abstractmethods__
expected = {"send_verification_code", "send_template_sms"}
assert expected.issubset(abstract_methods)
+139
View File
@@ -0,0 +1,139 @@
"""bgm_utils 单元测试."""
from __future__ import annotations
from domain.bgm_utils import merge_bgm_config
class TestMergeBgmConfigEmptyInputs:
"""空输入测试."""
def test_both_empty(self):
result = merge_bgm_config({}, {})
assert result == {}
def test_user_empty_returns_template_copy(self):
template = {"enabled": True, "volume": 0.5}
result = merge_bgm_config(template, {})
assert result == {"enabled": True, "volume": 0.5}
# 返回的是副本不是同一个对象
assert result is not template
def test_template_empty_returns_user_copy(self):
user = {"enabled": False, "volume": 0.8}
result = merge_bgm_config({}, user)
assert result == {"enabled": False, "volume": 0.8}
assert result is not user
def test_user_none_returns_template(self):
template = {"enabled": True}
result = merge_bgm_config(template, None) # type: ignore[arg-type]
assert result == template
def test_template_none_returns_user(self):
user = {"enabled": True}
result = merge_bgm_config(None, user) # type: ignore[arg-type]
assert result == user
class TestMergeBgmConfigBasicMerge:
"""基础合并测试."""
def test_user_overrides_template_field(self):
template = {"volume": 0.5, "fade_in": 1.0}
user = {"volume": 0.8}
result = merge_bgm_config(template, user)
assert result["volume"] == 0.8
assert result["fade_in"] == 1.0
def test_user_adds_new_field(self):
template = {"volume": 0.5}
user = {"fade_out": 2.0}
result = merge_bgm_config(template, user)
assert result["volume"] == 0.5
assert result["fade_out"] == 2.0
def test_all_fields_overridden(self):
template = {"enabled": True, "volume": 0.5, "track_id": "t1"}
user = {"enabled": False, "volume": 1.0, "track_id": "t2"}
result = merge_bgm_config(template, user)
assert result == {"enabled": False, "volume": 1.0, "track_id": "t2"}
class TestMergeBgmConfigEnabledSpecial:
"""enabled 特殊处理测试."""
def test_user_no_enabled_keeps_template_enabled_true(self):
template = {"enabled": True, "volume": 0.5}
user = {"volume": 0.8}
result = merge_bgm_config(template, user)
assert result["enabled"] is True
assert result["volume"] == 0.8
def test_user_no_enabled_keeps_template_enabled_false(self):
template = {"enabled": False, "volume": 0.5}
user = {"volume": 0.8}
result = merge_bgm_config(template, user)
assert result["enabled"] is False
assert result["volume"] == 0.8
def test_user_explicit_enabled_true_overrides_template_false(self):
template = {"enabled": False, "volume": 0.5}
user = {"enabled": True, "volume": 0.8}
result = merge_bgm_config(template, user)
assert result["enabled"] is True
assert result["volume"] == 0.8
def test_user_explicit_enabled_false_overrides_template_true(self):
template = {"enabled": True, "volume": 0.5}
user = {"enabled": False}
result = merge_bgm_config(template, user)
assert result["enabled"] is False
def test_template_no_enabled_user_no_enabled(self):
template = {"volume": 0.5}
user = {"volume": 0.8}
result = merge_bgm_config(template, user)
assert "enabled" not in result
assert result["volume"] == 0.8
def test_template_no_enabled_user_has_enabled(self):
template = {"volume": 0.5}
user = {"enabled": True, "volume": 0.8}
result = merge_bgm_config(template, user)
assert result["enabled"] is True
class TestMergeBgmConfigDoesNotMutate:
"""不修改原字典测试."""
def test_template_not_mutated(self):
template = {"enabled": True, "volume": 0.5}
original = dict(template)
user = {"volume": 0.8, "fade": 1.0}
merge_bgm_config(template, user)
assert template == original
def test_user_not_mutated(self):
template = {"enabled": True, "volume": 0.5}
user = {"volume": 0.8}
original = dict(user)
merge_bgm_config(template, user)
assert user == original
class TestMergeBgmConfigNestedDict:
"""嵌套字典合并测试(简单合并,非深合并)."""
def test_nested_dict_user_overrides(self):
template = {"effects": {"fade_in": 1.0, "fade_out": 1.0}}
user = {"effects": {"fade_in": 2.0}}
result = merge_bgm_config(template, user)
# 简单合并,用户effects整个覆盖模板的
assert result["effects"] == {"fade_in": 2.0}
def test_nested_dict_preserved_when_no_user_override(self):
template = {"effects": {"fade_in": 1.0}}
user = {"volume": 0.8}
result = merge_bgm_config(template, user)
assert result["effects"] == {"fade_in": 1.0}
+63
View File
@@ -0,0 +1,63 @@
"""EmailConfig 单元测试."""
from __future__ import annotations
import pytest
from domain.auth.email_service import EmailConfig
class TestEmailConfigDefaults:
"""默认值测试."""
def test_default_values(self):
config = EmailConfig()
assert config.smtp_host == "smtp.gmail.com"
assert config.smtp_port == 587
assert config.smtp_user == ""
assert config.smtp_password == ""
assert config.from_email == ""
assert config.from_name == "小虾 SaaS"
assert config.use_tls is True
def test_custom_construction(self):
config = EmailConfig(
smtp_host="smtp.example.com",
smtp_port=465,
smtp_user="user@example.com",
smtp_password="secret",
from_email="no-reply@example.com",
from_name="Example App",
use_tls=False,
)
assert config.smtp_host == "smtp.example.com"
assert config.smtp_port == 465
assert config.smtp_user == "user@example.com"
assert config.smtp_password == "secret"
assert config.from_email == "no-reply@example.com"
assert config.from_name == "Example App"
assert config.use_tls is False
def test_is_dataclass(self):
# 可重复创建相同配置
c1 = EmailConfig(smtp_host="h.com", smtp_port=25)
c2 = EmailConfig(smtp_host="h.com", smtp_port=25)
assert c1 == c2
class TestEmailConfigEquality:
"""相等性测试."""
def test_equal_same_values(self):
c1 = EmailConfig()
c2 = EmailConfig()
assert c1 == c2
def test_not_equal_different_host(self):
c1 = EmailConfig(smtp_host="a.com")
c2 = EmailConfig(smtp_host="b.com")
assert c1 != c2
def test_not_equal_different_port(self):
c1 = EmailConfig(smtp_port=587)
c2 = EmailConfig(smtp_port=465)
assert c1 != c2
+107
View File
@@ -0,0 +1,107 @@
"""domain exceptions 单元测试."""
from __future__ import annotations
import pytest
from domain.exceptions import (
DomainError,
NotFoundError,
QuotaExceededError,
ValidationError,
)
class TestDomainError:
"""DomainError 基类测试."""
def test_is_exception(self):
assert issubclass(DomainError, Exception)
def test_raise_and_catch(self):
with pytest.raises(DomainError):
raise DomainError("something went wrong")
def test_message(self):
err = DomainError("test message")
assert str(err) == "test message"
def test_empty_message(self):
err = DomainError()
assert str(err) == ""
class TestNotFoundError:
"""NotFoundError 测试."""
def test_is_domain_error(self):
assert issubclass(NotFoundError, DomainError)
def test_raise_and_catch_as_domain(self):
with pytest.raises(DomainError):
raise NotFoundError("user not found")
def test_raise_and_catch_specific(self):
with pytest.raises(NotFoundError):
raise NotFoundError("user not found")
def test_message(self):
err = NotFoundError("resource not found")
assert str(err) == "resource not found"
class TestValidationError:
"""ValidationError 测试."""
def test_is_domain_error(self):
assert issubclass(ValidationError, DomainError)
def test_raise_and_catch_as_domain(self):
with pytest.raises(DomainError):
raise ValidationError("invalid input")
def test_raise_and_catch_specific(self):
with pytest.raises(ValidationError):
raise ValidationError("invalid input")
def test_message(self):
err = ValidationError("bad data")
assert str(err) == "bad data"
class TestQuotaExceededError:
"""QuotaExceededError 测试."""
def test_is_domain_error(self):
assert issubclass(QuotaExceededError, DomainError)
def test_constructor_stores_fields(self):
err = QuotaExceededError("storage", limit=100.0, used=150.0)
assert err.dimension == "storage"
assert err.limit == 100.0
assert err.used == 150.0
def test_message_format(self):
err = QuotaExceededError("storage", limit=100.0, used=150.0)
assert "storage" in str(err)
assert "150.0" in str(err)
assert "100.0" in str(err)
assert "Quota exceeded" in str(err)
def test_raise_and_catch_as_domain(self):
with pytest.raises(DomainError):
raise QuotaExceededError("api_calls", limit=1000, used=2000)
def test_raise_and_catch_specific(self):
with pytest.raises(QuotaExceededError):
raise QuotaExceededError("api_calls", limit=1000, used=2000)
def test_int_values(self):
err = QuotaExceededError("count", limit=100, used=150)
assert err.limit == 100
assert err.used == 150
assert "150/100" in str(err)
def test_float_values(self):
err = QuotaExceededError("size", limit=10.5, used=20.3)
assert err.limit == 10.5
assert err.used == 20.3
+134
View File
@@ -0,0 +1,134 @@
"""EditTemplateVersion 单元测试."""
from __future__ import annotations
from datetime import datetime, timezone
import pytest
from domain.template_version import EditTemplateVersion
class TestEditTemplateVersionCreate:
"""create() 工厂方法测试."""
def test_create_basic(self):
v = EditTemplateVersion.create("tmpl_001", 1)
assert v.id is not None
assert len(v.id) == 32
assert v.template_id == "tmpl_001"
assert v.version == 1
assert v.name == ""
assert v.editing_mode == "one_take"
assert v.config == {}
assert v.clip_configs == []
assert v.change_note == ""
assert v.published_by == ""
assert v.created_at is not None
def test_create_with_all_fields(self):
v = EditTemplateVersion.create(
"tmpl_001",
3,
name="第三版",
editing_mode="pip",
config={"bgm": True},
clip_configs=[{"id": "c1", "type": "video"}],
change_note="优化剪辑逻辑",
published_by="user_123",
)
assert v.template_id == "tmpl_001"
assert v.version == 3
assert v.name == "第三版"
assert v.editing_mode == "pip"
assert v.config == {"bgm": True}
assert v.clip_configs == [{"id": "c1", "type": "video"}]
assert v.change_note == "优化剪辑逻辑"
assert v.published_by == "user_123"
def test_create_config_none_defaults_to_empty(self):
v = EditTemplateVersion.create("t1", 1, config=None)
assert v.config == {}
def test_create_clip_configs_none_defaults_to_empty(self):
v = EditTemplateVersion.create("t1", 1, clip_configs=None)
assert v.clip_configs == []
def test_create_unique_ids(self):
v1 = EditTemplateVersion.create("t1", 1)
v2 = EditTemplateVersion.create("t1", 1)
assert v1.id != v2.id
def test_create_sets_created_at(self):
before = datetime.now(timezone.utc)
v = EditTemplateVersion.create("t1", 1)
after = datetime.now(timezone.utc)
assert before <= v.created_at <= after
class TestEditTemplateVersionConstruction:
"""直接构造测试."""
def test_direct_construction(self):
now = datetime(2026, 1, 1, tzinfo=timezone.utc)
v = EditTemplateVersion(
id="v1",
template_id="t1",
version=5,
name="v5",
editing_mode="voice_over",
config={"key": "value"},
clip_configs=[{"a": 1}, {"b": 2}],
change_note="test",
published_by="admin",
created_at=now,
)
assert v.id == "v1"
assert v.template_id == "t1"
assert v.version == 5
assert v.name == "v5"
assert v.editing_mode == "voice_over"
assert v.config == {"key": "value"}
assert v.clip_configs == [{"a": 1}, {"b": 2}]
assert v.change_note == "test"
assert v.published_by == "admin"
assert v.created_at == now
def test_default_values(self):
v = EditTemplateVersion(id="v1", template_id="t1", version=1)
assert v.name == ""
assert v.editing_mode == "one_take"
assert v.config == {}
assert v.clip_configs == []
assert v.change_note == ""
assert v.published_by == ""
class TestEditTemplateVersionSlots:
"""slots 测试."""
def test_slots_no_extra_attrs(self):
v = EditTemplateVersion.create("t1", 1)
with pytest.raises((AttributeError, TypeError)):
v.new_field = "value" # type: ignore[attr-defined]
class TestEditTemplateVersionEquality:
"""相等性测试."""
def test_equal_same_id_and_version(self):
now = datetime(2026, 1, 1, tzinfo=timezone.utc)
v1 = EditTemplateVersion(id="same", template_id="t1", version=1, created_at=now)
v2 = EditTemplateVersion(id="same", template_id="t1", version=1, created_at=now)
assert v1 == v2
def test_not_equal_different_id(self):
now = datetime(2026, 1, 1, tzinfo=timezone.utc)
v1 = EditTemplateVersion(id="v1", template_id="t1", version=1, created_at=now)
v2 = EditTemplateVersion(id="v2", template_id="t1", version=1, created_at=now)
assert v1 != v2
def test_not_equal_different_version(self):
now = datetime(2026, 1, 1, tzinfo=timezone.utc)
v1 = EditTemplateVersion(id="same", template_id="t1", version=1, created_at=now)
v2 = EditTemplateVersion(id="same", template_id="t1", version=2, created_at=now)
assert v1 != v2
+209 -224
View File
@@ -1,14 +1,18 @@
"""path_security 单元测试."""
"""路径安全校验工具单元测试 — 路径遍历防护."""
from __future__ import annotations
import os
import sys
import tempfile
import unittest
from pathlib import Path
import pytest
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "apps", "worker"))
from apps.worker.video_processing.path_security import (
LOCAL_SCHEMA_PREFIX,
MAX_PATH_LENGTH,
from video_processing.path_security import ( # noqa: E402
PathSecurityError,
get_allowed_local_dirs,
is_in_allowed_dirs,
is_path_safe,
safe_resolve_path,
@@ -17,242 +21,223 @@ from apps.worker.video_processing.path_security import (
)
@pytest.fixture
def base_dir():
with tempfile.TemporaryDirectory() as tmpdir:
# 创建一个子文件用于测试
with open(os.path.join(tmpdir, "test.mp4"), "w") as f:
f.write("test")
subdir = os.path.join(tmpdir, "subdir")
os.makedirs(subdir)
with open(os.path.join(subdir, "audio.mp3"), "w") as f:
f.write("test")
yield tmpdir
class TestSafeResolvePath(unittest.TestCase):
"""安全路径解析测试."""
def setUp(self):
self.tmpdir = tempfile.mkdtemp()
def tearDown(self):
import shutil
shutil.rmtree(self.tmpdir, ignore_errors=True)
# ── 正常路径 ─────────────────────────────────────────────────────────
def test_simple_relative_path(self):
"""简单相对路径应该正常解析."""
result = safe_resolve_path("test.mp4", self.tmpdir)
self.assertEqual(result.name, "test.mp4")
self.assertTrue(str(result).startswith(self.tmpdir))
def test_subdirectory_path(self):
"""子目录路径应该正常解析."""
result = safe_resolve_path("sub/dir/file.mp4", self.tmpdir)
self.assertTrue(str(result).startswith(self.tmpdir))
self.assertIn("sub/dir/file.mp4", str(result).replace("\\", "/"))
def test_dot_slash_path(self):
"""./ 开头的路径应该正常解析."""
result = safe_resolve_path("./test.mp4", self.tmpdir)
self.assertEqual(result.name, "test.mp4")
# ── 路径遍历防护 ─────────────────────────────────────────────────────
def test_parent_traversal_rejected(self):
"""../ 路径遍历应该被拒绝."""
with self.assertRaises(PathSecurityError):
safe_resolve_path("../etc/passwd", self.tmpdir)
def test_multiple_parent_traversal_rejected(self):
"""多级 ../ 遍历应该被拒绝."""
with self.assertRaises(PathSecurityError):
safe_resolve_path("../../etc/passwd", self.tmpdir)
def test_mixed_traversal_rejected(self):
"""混合路径遍历应该被拒绝."""
with self.assertRaises(PathSecurityError):
safe_resolve_path("./sub/../../etc/shadow", self.tmpdir)
def test_absolute_path_rejected(self):
"""绝对路径(超出基目录)应该被拒绝."""
with self.assertRaises(PathSecurityError):
safe_resolve_path("/etc/passwd", self.tmpdir)
# ── 空字节注入 ───────────────────────────────────────────────────────
def test_null_byte_rejected(self):
"""空字节注入应该被拒绝."""
with self.assertRaises(PathSecurityError):
safe_resolve_path("test\x00.mp4", self.tmpdir)
# ── 空路径 ──────────────────────────────────────────────────────────
def test_empty_path_rejected(self):
"""空路径应该被拒绝."""
with self.assertRaises(PathSecurityError):
safe_resolve_path("", self.tmpdir)
def test_none_path_rejected(self):
"""None 路径应该被拒绝."""
with self.assertRaises(PathSecurityError):
safe_resolve_path(None, self.tmpdir) # type: ignore
def test_whitespace_path_rejected(self):
"""空白路径应该被拒绝."""
with self.assertRaises(PathSecurityError):
safe_resolve_path(" ", self.tmpdir)
# ── 路径长度 ────────────────────────────────────────────────────────
def test_too_long_path_rejected(self):
"""超长路径应该被拒绝."""
long_path = "a" * 5000 + ".mp4"
with self.assertRaises(PathSecurityError):
safe_resolve_path(long_path, self.tmpdir)
# ── 系统路径防护 ─────────────────────────────────────────────────────
def test_proc_path_rejected_when_absolute(self):
"""/proc/ 路径在绝对路径模式下应该被拒绝(因为超出基目录)."""
with self.assertRaises(PathSecurityError):
safe_resolve_path("/proc/self/environ", self.tmpdir)
# ── 扩展名校验 ───────────────────────────────────────────────────────
def test_extension_whitelist_pass(self):
"""白名单内的扩展名应该通过."""
result = safe_resolve_path(
"test.mp4",
self.tmpdir,
allowed_extensions={".mp4", ".mov"},
)
self.assertEqual(result.suffix.lower(), ".mp4")
def test_extension_whitelist_reject(self):
"""白名单外的扩展名应该被拒绝."""
with self.assertRaises(PathSecurityError):
safe_resolve_path(
"test.exe",
self.tmpdir,
allowed_extensions={".mp4", ".mov"},
)
# ── safe_resolve_path ────────────────────────────────────────────────────────
class TestLocalSchemaPath(unittest.TestCase):
"""local:// schema 路径测试."""
def setUp(self):
self.tmpdir = tempfile.mkdtemp()
def tearDown(self):
import shutil
shutil.rmtree(self.tmpdir, ignore_errors=True)
def test_valid_local_schema(self):
"""有效的 local:// 相对路径应该通过."""
# 创建测试文件
test_file = Path(self.tmpdir) / "test.mp4"
test_file.touch()
result = validate_local_schema_path("local://test.mp4", self.tmpdir)
self.assertTrue(result.exists())
def test_local_schema_absolute_rejected(self):
"""local:// + 绝对路径应该被拒绝."""
with self.assertRaises(PathSecurityError):
validate_local_schema_path("local:///etc/passwd", self.tmpdir)
def test_local_schema_traversal_rejected(self):
"""local:// + 路径遍历应该被拒绝."""
with self.assertRaises(PathSecurityError):
validate_local_schema_path("local://../etc/passwd", self.tmpdir)
def test_non_local_schema_rejected(self):
"""非 local:// 开头的路径应该被拒绝."""
with self.assertRaises(PathSecurityError):
validate_local_schema_path("http://example.com/test", self.tmpdir)
class TestSafeResolvePath:
def test_none_path_raises(self, base_dir):
with pytest.raises(PathSecurityError, match="不能为空"):
safe_resolve_path(None, base_dir)
class TestSanitizeFilename(unittest.TestCase):
"""文件名清理测试."""
def test_empty_string_raises(self, base_dir):
with pytest.raises(PathSecurityError, match="不能为空"):
safe_resolve_path("", base_dir)
def test_whitespace_path_raises(self, base_dir):
with pytest.raises(PathSecurityError, match="不能为空"):
safe_resolve_path(" ", base_dir)
def test_too_long_path_raises(self, base_dir):
long_path = "a" * (MAX_PATH_LENGTH + 1)
with pytest.raises(PathSecurityError, match="路径过长"):
safe_resolve_path(long_path, base_dir)
def test_null_byte_raises(self, base_dir):
with pytest.raises(PathSecurityError, match="空字节"):
safe_resolve_path("file\x00.mp4", base_dir)
def test_relative_path_within_base(self, base_dir):
result = safe_resolve_path("test.mp4", base_dir)
assert result.name == "test.mp4"
assert str(result).startswith(str(os.path.realpath(base_dir)))
def test_subdirectory_path(self, base_dir):
result = safe_resolve_path("subdir/audio.mp3", base_dir)
assert result.name == "audio.mp3"
assert "subdir" in str(result)
def test_parent_traversal_raises(self, base_dir):
with pytest.raises(PathSecurityError, match="路径遍历"):
safe_resolve_path("../etc/passwd", base_dir)
def test_nested_parent_traversal_raises(self, base_dir):
with pytest.raises(PathSecurityError, match="路径遍历"):
safe_resolve_path("subdir/../../etc/passwd", base_dir)
def test_absolute_path_raises(self, base_dir):
with pytest.raises(PathSecurityError, match="绝对路径"):
safe_resolve_path("/etc/passwd", base_dir)
def test_absolute_path_with_allow_outside(self, base_dir):
# allow_outside=True 时允许绝对路径(但会被危险路径模式检查)
with pytest.raises(PathSecurityError, match="系统路径"):
safe_resolve_path("/etc/passwd", base_dir, allow_outside=True)
def test_local_schema_relative(self, base_dir):
result = safe_resolve_path("local://test.mp4", base_dir)
assert result.name == "test.mp4"
assert str(result).startswith(str(os.path.realpath(base_dir)))
def test_local_schema_absolute_raises(self, base_dir):
with pytest.raises(PathSecurityError, match="绝对路径"):
safe_resolve_path("local:///etc/passwd", base_dir)
def test_local_schema_traversal_raises(self, base_dir):
with pytest.raises(PathSecurityError, match="路径遍历"):
safe_resolve_path("local://../secret", base_dir)
def test_invalid_base_dir_raises(self):
with pytest.raises(PathSecurityError, match="基路径"):
safe_resolve_path("file.txt", "/nonexistent/dir")
def test_allowed_extensions_valid(self, base_dir):
result = safe_resolve_path("test.mp4", base_dir, allowed_extensions={".mp4"})
assert result.suffix.lower() == ".mp4"
def test_allowed_extensions_invalid_raises(self, base_dir):
with pytest.raises(PathSecurityError, match="文件类型"):
safe_resolve_path("test.mp4", base_dir, allowed_extensions={".mp3"})
def test_no_extension_restriction(self, base_dir):
# allowed_extensions=None 时不检查
result = safe_resolve_path("test.mp4", base_dir, allowed_extensions=None)
assert result is not None
def test_path_object_input(self, base_dir):
from pathlib import Path
result = safe_resolve_path(Path("test.mp4"), base_dir)
assert result.name == "test.mp4"
def test_path_object_base_dir(self, base_dir):
from pathlib import Path
result = safe_resolve_path("test.mp4", Path(base_dir))
assert result.name == "test.mp4"
# ── is_path_safe ────────────────────────────────────────────────────────────
class TestIsPathSafe:
def test_safe_path_returns_true(self, base_dir):
assert is_path_safe("test.mp4", base_dir) is True
def test_unsafe_path_returns_false(self, base_dir):
assert is_path_safe("../etc/passwd", base_dir) is False
def test_none_returns_false(self, base_dir):
assert is_path_safe(None, base_dir) is False
# ── validate_local_schema_path ──────────────────────────────────────────────
class TestValidateLocalSchemaPath:
def test_valid_local_path(self, base_dir):
result = validate_local_schema_path("local://test.mp4", base_dir)
assert result.name == "test.mp4"
def test_missing_prefix_raises(self, base_dir):
with pytest.raises(PathSecurityError, match="开头"):
validate_local_schema_path("test.mp4", base_dir)
def test_traversal_raises(self, base_dir):
with pytest.raises(PathSecurityError):
validate_local_schema_path("local://../secret", base_dir)
def test_absolute_path_raises(self, base_dir):
with pytest.raises(PathSecurityError):
validate_local_schema_path("local:///etc/passwd", base_dir)
# ── sanitize_filename ───────────────────────────────────────────────────────
class TestSanitizeFilename:
def test_normal_filename(self):
assert sanitize_filename("hello.mp4") == "hello.mp4"
"""正常文件名应该保持不变."""
self.assertEqual(sanitize_filename("video.mp4"), "video.mp4")
def test_empty_returns_unnamed(self):
assert sanitize_filename("") == "unnamed"
def test_path_separators_removed(self):
"""路径分隔符应该被替换."""
self.assertNotIn("/", sanitize_filename("../path/to/file.mp4"))
self.assertNotIn("\\", sanitize_filename("..\\path\\file.mp4"))
def test_none_default(self):
# 空字符串会返回unnamed
assert sanitize_filename("") == "unnamed"
def test_leading_dots_removed(self):
"""开头的点应该被移除."""
result = sanitize_filename(".hidden")
self.assertFalse(result.startswith("."))
self.assertEqual(result, "hidden")
def test_removes_path_separators(self):
assert "/" not in sanitize_filename("path/to/file.mp4")
assert "\\" not in sanitize_filename("path\\to\\file.mp4")
def test_multiple_leading_dots_removed(self):
"""多个开头的点应该全部被移除."""
result = sanitize_filename("...hidden")
self.assertFalse(result.startswith("."))
def test_removes_control_characters(self):
result = sanitize_filename("file\x01\x02name.mp4")
assert "\x01" not in result
assert "\x02" not in result
def test_empty_filename_default(self):
"""空文件名应该返回 unnamed."""
self.assertEqual(sanitize_filename(""), "unnamed")
def test_removes_dangerous_chars(self):
result = sanitize_filename("file<name>.mp4")
assert "<" not in result
assert ">" not in result
def test_special_chars_removed(self):
"""特殊字符应该被替换."""
result = sanitize_filename('file<name>:"test|?*.mp4')
self.assertNotIn("<", result)
self.assertNotIn(">", result)
self.assertNotIn(":", result)
self.assertNotIn('"', result)
self.assertNotIn("|", result)
self.assertNotIn("?", result)
self.assertNotIn("*", result)
def test_removes_leading_dots(self):
assert not sanitize_filename(".hidden").startswith(".")
assert not sanitize_filename("..hidden").startswith(".")
def test_chinese_characters_preserved(self):
result = sanitize_filename("视频文件.mp4")
assert "视频文件" in result
def test_chinese_filename_preserved(self):
"""中文文件名应该保留."""
result = sanitize_filename("视频素材.mp4")
self.assertIn("视频素材", result)
def test_long_filename_truncated(self):
"""超长文件名应该被截断."""
long_name = "a" * 300 + ".mp4"
result = sanitize_filename(long_name)
assert len(result) <= 255
assert result.endswith(".mp4")
def test_spaces_preserved(self):
result = sanitize_filename("my file.mp4")
assert "my file.mp4" == result
def test_underscores_hyphens_preserved(self):
result = sanitize_filename("my_file-name.mp4")
assert result == "my_file-name.mp4"
def test_all_dots_returns_unnamed(self):
assert sanitize_filename("...") == "unnamed"
self.assertLessEqual(len(result), 255)
self.assertTrue(result.endswith(".mp4"))
# ── is_in_allowed_dirs ──────────────────────────────────────────────────────
class TestAllowedDirs(unittest.TestCase):
"""允许目录配置测试."""
def test_get_allowed_dirs_returns_list(self):
"""get_allowed_local_dirs 应该返回列表."""
dirs = get_allowed_local_dirs()
self.assertIsInstance(dirs, list)
def test_is_in_allowed_dirs_tmp(self):
"""/tmp 应该在默认允许目录内."""
self.assertTrue(is_in_allowed_dirs("/tmp/test.mp4"))
def test_is_path_safe_convenience(self):
"""is_path_safe 便捷函数应该正常工作."""
with tempfile.TemporaryDirectory() as tmpdir:
self.assertTrue(is_path_safe("test.mp4", tmpdir))
self.assertFalse(is_path_safe("../etc/passwd", tmpdir))
class TestIsInAllowedDirs:
def test_path_in_allowed_dir(self, base_dir):
filepath = os.path.join(base_dir, "test.mp4")
from pathlib import Path
assert is_in_allowed_dirs(filepath, [Path(base_dir)]) is True
def test_path_not_in_allowed_dir(self, base_dir):
from pathlib import Path
assert is_in_allowed_dirs("/etc/passwd", [Path(base_dir)]) is False
def test_subdirectory_in_allowed(self, base_dir):
from pathlib import Path
sub = os.path.join(base_dir, "subdir", "audio.mp3")
assert is_in_allowed_dirs(sub, [Path(base_dir)]) is True
def test_none_allowed_dirs_uses_default(self):
# None 使用默认配置(包含 /tmp)
result = is_in_allowed_dirs("/tmp/test.mp4")
assert isinstance(result, bool)
def test_allowed_dirs_list_is_empty(self):
from pathlib import Path
assert is_in_allowed_dirs("/tmp/test", []) is False
# ── PathSecurityError class ─────────────────────────────────────────────────
class TestPathSecurityError:
def test_is_value_error(self):
assert issubclass(PathSecurityError, ValueError)
def test_message_preserved(self):
err = PathSecurityError("test message")
assert str(err) == "test message"
if __name__ == "__main__":
unittest.main()