mirror of
https://github.com/MemeMeow-Studio/MemeMeow.git
synced 2026-10-17 04:41:01 +08:00
230 lines
11 KiB
Python
230 lines
11 KiB
Python
"""不依赖外部数据库的 ORM 约束和 migration 入口检查。"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import ast
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
from alembic.config import Config
|
|
from alembic.script import ScriptDirectory
|
|
from sqlalchemy import create_engine
|
|
from sqlalchemy.orm import Session
|
|
|
|
from backend.database import AgentCallbackRequest, AgentCallbackRequestRepository, DatabaseError, GLOBAL_LANE_RESOURCE_KEY, ScopeContext, TaskLaneFairness, TaskLaneResourceSlot
|
|
from backend import database
|
|
from backend import metadata
|
|
from backend.persistence import models
|
|
|
|
|
|
def test_single_forward_migration_head():
|
|
"""仓库只暴露一个前向 revision head,回滚由 migration 明确拒绝。"""
|
|
script = ScriptDirectory.from_config(Config("alembic.ini"))
|
|
assert script.get_heads() == ["0028_image_library_sort"]
|
|
assert (Path("alembic/versions/0001_postgres_scoped.py")).is_file()
|
|
|
|
|
|
def test_content_identity_migration_preflights_history_and_installs_strict_unique_constraint():
|
|
"""内容身份迁移必须拒绝历史脏记录并安装 scope 内唯一约束。"""
|
|
migration = Path("alembic/versions/0022_separate_image_display_names_and_content_identity.py").read_text(encoding="utf-8")
|
|
assert "image_identity_preflight_failed" in migration
|
|
assert "GROUP BY scope_id, sha256, extension" in migration
|
|
assert "uq_memes_scope_content" in migration
|
|
assert "NOT VALID" not in migration
|
|
assert "ALTER COLUMN display_name SET NOT NULL" in migration
|
|
assert "ALTER COLUMN metadata_schema_version SET DEFAULT 2" not in migration
|
|
assert "ALTER COLUMN metadata_schema_version SET DEFAULT 1" in migration
|
|
assert metadata.SCHEMA_VERSION == 1
|
|
assert models.Meme.metadata_schema_version.default.arg == 1
|
|
assert models.Meme.metadata_schema_version.server_default.arg.text == "1"
|
|
assert "regexp_replace(storage_key" not in migration
|
|
|
|
|
|
def test_image_processing_fixed_plan_migration_is_forward_only_and_fail_closed_on_active_history():
|
|
"""图片处理计划迁移必须回填历史、建立目标索引并阻止未归类活动任务。"""
|
|
migration = Path("alembic/versions/0023_image_processing_fixed_plans.py").read_text(encoding="utf-8")
|
|
assert 'down_revision = "0022_separate_image_display_names_and_content_identity"' in migration
|
|
assert "processing_mode" in migration
|
|
assert "target_meme_id" in migration and "target_image_sha256" in migration
|
|
assert "image_processing_history_unresolved" in migration
|
|
assert "ix_tasks_image_target_active" in migration
|
|
assert "raise RuntimeError" in migration
|
|
|
|
|
|
def test_image_processing_retry_hardening_checks_active_task_target_identity():
|
|
"""0024 必须接在固定计划迁移后,并拒绝 SHA 已变化的活动历史 Task。"""
|
|
migration = Path("alembic/versions/0024_image_processing_retry_hardening.py").read_text(encoding="utf-8")
|
|
assert 'down_revision = "0023_image_processing_fixed_plans"' in migration
|
|
assert "LEFT JOIN memes AS meme" in migration
|
|
assert "lower(meme.sha256) <> lower(task.target_image_sha256)" in migration
|
|
assert "image_processing_history_unresolved" in migration
|
|
assert "raise RuntimeError" in migration
|
|
|
|
|
|
def test_image_processing_migration_is_chained_and_rebuilds_legacy_checks():
|
|
"""0012 必须从 0011 升级,并显式重建旧三阶段约束。"""
|
|
migration = Path("alembic/versions/0012_image_processing_options_auto_rename.py").read_text(encoding="utf-8")
|
|
assert 'down_revision = "0011_harden_operation_grant_association"' in migration
|
|
assert "ADD COLUMN IF NOT EXISTS auto_name BOOLEAN NOT NULL DEFAULT FALSE" in migration
|
|
assert "DROP CONSTRAINT IF EXISTS ck_task_image_stage" in migration
|
|
assert "DROP CONSTRAINT IF EXISTS ck_task_image_stage_type" in migration
|
|
assert "DROP CONSTRAINT IF EXISTS ck_image_processing_stage_name" in migration
|
|
assert "DROP CONSTRAINT IF EXISTS ck_image_processing_stage_status" in migration
|
|
assert "image_auto_rename" in migration
|
|
assert "'skipped','warning'" in migration
|
|
|
|
|
|
def test_startup_compatibility_ddl_closes_nullable_auto_name():
|
|
"""启动兼容路径必须把旧可空列收束为安全的非空默认值。"""
|
|
database = Path("backend/persistence/engine.py").read_text(encoding="utf-8")
|
|
assert "UPDATE image_processing_jobs SET auto_name = FALSE WHERE auto_name IS NULL" in database
|
|
assert "ALTER TABLE image_processing_jobs ALTER COLUMN auto_name SET NOT NULL" in database
|
|
|
|
|
|
def test_callback_binding_migration_is_forward_only_and_fail_closed_on_history_conflicts():
|
|
"""callback 迁移先检查缺失/重复事实,再安装逻辑唯一索引。"""
|
|
migration = Path("alembic/versions/0015_bind_agent_callback_request_ids.py").read_text(encoding="utf-8")
|
|
assert 'down_revision = "0014_scope_aware_opencode_workspace"' in migration
|
|
assert "incomplete_count" in migration
|
|
assert "duplicate_count" in migration
|
|
assert "历史重复逻辑绑定" in migration
|
|
assert "CREATE UNIQUE INDEX IF NOT EXISTS uq_agent_callback_requests_logical" in migration
|
|
assert "raise RuntimeError" in migration
|
|
|
|
|
|
def test_fair_scheduling_migration_is_chained_and_forward_only():
|
|
"""公平状态迁移必须接在 callback head 后,并拒绝危险回滚。"""
|
|
migration = Path("alembic/versions/0016_agent_fair_scheduling.py").read_text(encoding="utf-8")
|
|
assert 'down_revision = "0015_bind_agent_callback_request_ids"' in migration
|
|
assert "task_lane_fairness" in migration
|
|
assert "last_dispatch_sequence" in migration
|
|
assert "raise RuntimeError" in migration
|
|
|
|
|
|
def test_resource_scheduling_migration_backfills_global_resource_and_adds_resource_slots():
|
|
"""资源迁移必须回填旧任务、公平行并安装独立资源槽位表。"""
|
|
migration = Path("alembic/versions/0019_task_lane_resource_scheduling.py").read_text(encoding="utf-8")
|
|
assert 'down_revision = "0018_operation_grant_metering_units"' in migration
|
|
assert "lane_resource_key" in migration
|
|
assert "__global__" in migration
|
|
assert "task_lane_resource_slots" in migration
|
|
assert "PRIMARY KEY (lane, resource_key, slot_number)" in migration
|
|
assert "PRIMARY KEY (lane, resource_key, scope_id)" in migration
|
|
assert "raise RuntimeError" in migration
|
|
|
|
|
|
def test_callback_model_keeps_request_id_and_logic_unique_facts():
|
|
"""ORM callback 表同时保留旧 request ID 主键和新的复合逻辑唯一约束。"""
|
|
table = AgentCallbackRequest.__table__
|
|
assert {column.name for column in table.primary_key.columns} == {"scope_id", "request_id"}
|
|
assert any(constraint.name == "uq_agent_callback_requests_logical" for constraint in table.constraints)
|
|
|
|
|
|
def test_fairness_model_is_lane_scope_keyed_and_has_dispatch_index():
|
|
"""公平事实以 lane/资源/scope 为复合主键,并可按持久序号排序。"""
|
|
table = TaskLaneFairness.__table__
|
|
assert {column.name for column in table.primary_key.columns} == {"lane", "resource_key", "scope_id"}
|
|
assert {column.name for column in table.columns} >= {"resource_key", "last_dispatch_sequence", "created_at", "updated_at"}
|
|
assert any(index.name == "ix_task_lane_fairness_dispatch" for index in table.indexes)
|
|
|
|
|
|
def test_resource_slot_model_keeps_global_slot_separate_from_resource_slot():
|
|
"""资源槽位使用独立表,旧全局槽位表仍保留原主键。"""
|
|
table = TaskLaneResourceSlot.__table__
|
|
assert {column.name for column in table.primary_key.columns} == {"lane", "resource_key", "slot_number"}
|
|
assert {column.name for column in table.columns} >= {"resource_key", "claim_generation", "lease_expires_at"}
|
|
assert GLOBAL_LANE_RESOURCE_KEY == "__global__"
|
|
|
|
|
|
def test_callback_repository_fails_closed_without_postgres_schema():
|
|
"""callback repository 不回退到非 PostgreSQL 或 request-ID-only 事实层。"""
|
|
engine = create_engine("sqlite:///:memory:")
|
|
with Session(engine) as session:
|
|
repository = AgentCallbackRequestRepository(session, ScopeContext("local"))
|
|
with pytest.raises(DatabaseError, match="callback_binding_schema_unavailable"):
|
|
repository.ensure_schema_ready()
|
|
|
|
|
|
def test_database_facade_reexports_one_model_declaration_source():
|
|
"""旧 database 导入路径必须指向持久化模型模块的同一组对象。"""
|
|
model_names = (
|
|
"Base",
|
|
"ScopeContext",
|
|
"Scope",
|
|
"InstallationState",
|
|
"Meme",
|
|
"MemeCollection",
|
|
"MemeCollectionItem",
|
|
"StorageOperation",
|
|
"SearchGeneration",
|
|
"SearchHead",
|
|
"MemeEmbedding",
|
|
"MemeVisualEmbedding",
|
|
"Task",
|
|
"ReverseImageUsageEvent",
|
|
"AgentCallbackRequest",
|
|
"OperationGrant",
|
|
"ImageProcessingJob",
|
|
"ImageProcessingStage",
|
|
"ImageProcessingAttempt",
|
|
"MemeTextEmbedding",
|
|
"SearchMigrationState",
|
|
"TaskBatch",
|
|
"TaskBatchItem",
|
|
"TaskLaneResourceSlot",
|
|
"TaskLaneSlot",
|
|
"TaskLaneFairness",
|
|
)
|
|
for name in model_names:
|
|
assert getattr(database, name) is getattr(models, name)
|
|
assert getattr(models, name).__module__ == models.__name__
|
|
assert database.EMBEDDING_DIMENSIONS == models.EMBEDDING_DIMENSIONS
|
|
assert database.VISUAL_EMBEDDING_DIMENSIONS == models.VISUAL_EMBEDDING_DIMENSIONS
|
|
assert database.UTC is models.UTC
|
|
assert database.utcnow is models.utcnow
|
|
assert database.OPTIONAL_CONTROL_TABLES is models.OPTIONAL_CONTROL_TABLES
|
|
assert database.Base.metadata is models.Base.metadata
|
|
for name in (
|
|
"BigInteger",
|
|
"Boolean",
|
|
"CheckConstraint",
|
|
"DateTime",
|
|
"DeclarativeBase",
|
|
"ForeignKey",
|
|
"ForeignKeyConstraint",
|
|
"Index",
|
|
"Integer",
|
|
"JSON",
|
|
"JSONB",
|
|
"Mapped",
|
|
"String",
|
|
"UniqueConstraint",
|
|
"Uuid",
|
|
"Vector",
|
|
"mapped_column",
|
|
"timezone",
|
|
"unicodedata",
|
|
):
|
|
assert getattr(database, name) is getattr(models, name)
|
|
|
|
|
|
def test_model_module_does_not_reintroduce_database_or_runtime_boundaries():
|
|
"""模型模块不能反向依赖 facade、Repository 或文件存储装配。"""
|
|
source = Path(models.__file__).read_text(encoding="utf-8")
|
|
tree = ast.parse(source)
|
|
imported_modules = {
|
|
alias.name
|
|
for node in ast.walk(tree)
|
|
if isinstance(node, ast.Import)
|
|
for alias in node.names
|
|
}
|
|
imported_modules.update(
|
|
node.module
|
|
for node in ast.walk(tree)
|
|
if isinstance(node, ast.ImportFrom) and node.module is not None
|
|
)
|
|
assert "backend.database" not in imported_modules
|
|
assert "StorageCoordinator" not in source
|
|
assert "BlobStore" not in source
|
|
assert "class Scope(" not in Path(database.__file__).read_text(encoding="utf-8")
|