mirror of
https://github.com/MemeMeow-Studio/MemeMeow.git
synced 2026-10-18 21:52:59 +08:00
5ecda39843
- Implement tests for the ImageProcessingWorker to validate task deduplication and policy interactions. - Create tests for operation policy to ensure proper grant handling and error responses. - Add tests for reverse image provider response structure to validate empty and malformed results. - Develop comprehensive tests for scope resolution and service validation to ensure correct behavior in various scenarios.
819 lines
46 KiB
Python
819 lines
46 KiB
Python
"""PostgreSQL 集成测试夹具与数据库边界验收。
|
|
|
|
这些测试只在显式提供 ``MEMEMEOW_TEST_DATABASE_URL`` 时运行,避免本地单元测试
|
|
悄悄连接开发数据库。每个测试使用事务回滚清理数据,并验证目标数据库确实启用
|
|
pgvector、支持事务/锁和固定维度向量。
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import hashlib
|
|
import threading
|
|
import time
|
|
from concurrent.futures import ThreadPoolExecutor
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
from uuid import uuid4
|
|
from collections.abc import Iterator
|
|
|
|
import pytest
|
|
from sqlalchemy import select, text
|
|
from sqlalchemy.engine import Engine
|
|
from sqlalchemy.exc import IntegrityError
|
|
from sqlalchemy.orm import Session
|
|
|
|
from backend.database import (
|
|
EMBEDDING_DIMENSIONS,
|
|
VISUAL_EMBEDDING_DIMENSIONS,
|
|
DatabaseError,
|
|
Meme,
|
|
MemeEmbedding,
|
|
MemeVisualEmbedding,
|
|
Scope,
|
|
SearchGeneration,
|
|
SearchHead,
|
|
Task,
|
|
TaskBatch,
|
|
TaskBatchItem,
|
|
DatabaseResources,
|
|
ScopeContext,
|
|
StorageCoordinator,
|
|
StorageOperation,
|
|
TaskLaneSlot,
|
|
AgentCallbackRequest,
|
|
ImageProcessingJob,
|
|
ImageProcessingStage,
|
|
OperationGrant,
|
|
SearchMigrationState,
|
|
utcnow,
|
|
create_engine_for_url,
|
|
)
|
|
from backend.config import Settings
|
|
from backend.image_processing import ImageProcessingError, ImageProcessingRepository, ImageProcessingWorker, STAGES
|
|
from backend.metadata import MemeContext
|
|
from backend.operation_policy import AllowAllOperationPolicy, GrantAssociationStore, OperationPolicyGateway, Operations, PersistentGrantAssociationStore
|
|
from backend.visual import VisualSearchError, VisualSearchService
|
|
from backend.scope import ScopeServiceFactory
|
|
from backend.pg_services import PostgresTaskWorkerManager
|
|
|
|
|
|
class _CountingAllowAllPolicy(AllowAllOperationPolicy):
|
|
"""记录真实 acquire 请求,供 Worker 并发计量边界集成测试使用。"""
|
|
|
|
def __init__(self) -> None:
|
|
"""初始化线程安全的 acquire 观测列表。"""
|
|
super().__init__()
|
|
self.requests = []
|
|
self._requests_lock = threading.Lock()
|
|
|
|
def acquire(self, request):
|
|
"""记录可信 operation request 后复用 allow-all 的幂等授权。"""
|
|
with self._requests_lock:
|
|
self.requests.append(request)
|
|
return super().acquire(request)
|
|
|
|
|
|
def _test_database_url() -> str | None:
|
|
"""读取集成测试专用连接串;不使用应用默认数据库。"""
|
|
return os.getenv("MEMEMEOW_TEST_DATABASE_URL")
|
|
|
|
|
|
@pytest.fixture(scope="session")
|
|
def postgres_engine() -> Iterator[Engine]:
|
|
"""创建测试连接池,连接失败时将整组测试标记为跳过。"""
|
|
url = _test_database_url()
|
|
if not url:
|
|
pytest.skip("未设置 MEMEMEOW_TEST_DATABASE_URL,跳过 PostgreSQL 集成测试")
|
|
engine = create_engine_for_url(url, pool_size=2, max_overflow=0)
|
|
try:
|
|
with engine.connect() as connection:
|
|
connection.execute(text("SELECT 1"))
|
|
vector_enabled = bool(connection.execute(text("SELECT 1 FROM pg_extension WHERE extname='vector'")).scalar())
|
|
except Exception as exc: # noqa: BLE001
|
|
engine.dispose()
|
|
pytest.skip(f"PostgreSQL 集成数据库不可用: {exc}")
|
|
if not vector_enabled:
|
|
engine.dispose()
|
|
pytest.fail("测试数据库缺少 pgvector 扩展")
|
|
try:
|
|
yield engine
|
|
finally:
|
|
engine.dispose()
|
|
|
|
|
|
@pytest.fixture
|
|
def postgres_connection(postgres_engine: Engine):
|
|
"""提供测试事务连接;测试结束后回滚所有写入。"""
|
|
connection = postgres_engine.connect()
|
|
transaction = connection.begin()
|
|
try:
|
|
yield connection
|
|
finally:
|
|
transaction.rollback()
|
|
connection.close()
|
|
|
|
|
|
def test_postgres_baseline_supports_extension_transactions_and_vector(postgres_connection) -> None:
|
|
"""验证扩展、事务回滚和固定 1024 维向量可用。"""
|
|
connection = postgres_connection
|
|
assert connection.execute(text("SELECT 1 FROM pg_extension WHERE extname='vector'")).scalar() == 1
|
|
assert connection.execute(text("SELECT 1")).scalar() == 1
|
|
assert connection.execute(text("SELECT '[1,2,3]'::vector <=> '[1,2,3]'::vector")).scalar() == 0
|
|
# pgvector 类型本身的维度约束由业务 schema 验收;这里检查配置常量避免测试漂移。
|
|
assert EMBEDDING_DIMENSIONS == 1024
|
|
|
|
|
|
def test_postgres_transaction_isolation_fixture_rolls_back(postgres_connection) -> None:
|
|
"""测试夹具应在测试结束时回滚,而不清理共享数据库中的其他数据。"""
|
|
connection = postgres_connection
|
|
connection.execute(text("CREATE TEMP TABLE scoped_fixture_probe (value integer) ON COMMIT DROP"))
|
|
connection.execute(text("INSERT INTO scoped_fixture_probe(value) VALUES (1)"))
|
|
assert connection.execute(text("SELECT count(*) FROM scoped_fixture_probe")).scalar() == 1
|
|
|
|
|
|
def test_postgres_advisory_lock_is_transaction_scoped(postgres_connection) -> None:
|
|
"""数据库锁函数应在当前事务内可调用,供任务 lane 互斥使用。"""
|
|
postgres_connection.execute(text("SELECT pg_advisory_xact_lock(hashtext('mememeow:test'))"))
|
|
assert postgres_connection.execute(text("SELECT 1")).scalar() == 1
|
|
|
|
|
|
def test_postgres_composite_scope_foreign_keys_reject_mixed_scope_rows(postgres_connection) -> None:
|
|
"""直接绕过 repository 写入跨 scope 关联时,数据库复合外键必须拒绝。"""
|
|
suffix = uuid4().hex
|
|
scope_a = Scope(id=f"test-a-{suffix}")
|
|
scope_b = Scope(id=f"test-b-{suffix}")
|
|
meme_a = Meme(scope_id=scope_a.id, storage_key="same.png", extension=".png", size_bytes=1, sha256="a" * 64, meme_context={}, provenance={}, extensions={})
|
|
meme_b = Meme(scope_id=scope_b.id, storage_key="same.png", extension=".png", size_bytes=1, sha256="b" * 64, meme_context={}, provenance={}, extensions={})
|
|
generation_a = SearchGeneration(scope_id=scope_a.id, model="test-model", source_snapshot_hash="a" * 64)
|
|
generation_b = SearchGeneration(scope_id=scope_b.id, model="test-model", source_snapshot_hash="b" * 64)
|
|
batch_a = TaskBatch(scope_id=scope_a.id, batch_id=f"batch-a-{suffix}")
|
|
task_b = Task(id=f"task-b-{suffix}", scope_id=scope_b.id, task_type="test", lane="default", payload={})
|
|
session = Session(bind=postgres_connection, expire_on_commit=False)
|
|
try:
|
|
session.add_all([scope_a, scope_b])
|
|
session.flush()
|
|
session.add_all([meme_a, meme_b, generation_a, generation_b, batch_a, task_b])
|
|
session.flush()
|
|
|
|
def assert_rejected(row) -> None:
|
|
"""在 SAVEPOINT 中验证单条非法关系,保留外层测试事务。"""
|
|
nested = session.begin_nested()
|
|
try:
|
|
session.add(row)
|
|
with pytest.raises(IntegrityError):
|
|
session.flush()
|
|
finally:
|
|
nested.rollback()
|
|
|
|
assert_rejected(MemeEmbedding(scope_id=scope_a.id, generation_id=generation_b.id, meme_id=meme_a.id, semantic_document="x", semantic_document_hash="c" * 64, metadata_hash="d" * 64, image_sha256="a" * 64, meme_revision=1))
|
|
assert_rejected(SearchHead(scope_id=scope_a.id, model="test-model", active_generation_id=generation_b.id, active_generation_model="test-model"))
|
|
assert_rejected(TaskBatchItem(scope_id=scope_a.id, batch_id=batch_a.batch_id, task_id=task_b.id))
|
|
finally:
|
|
session.close()
|
|
|
|
|
|
def test_callback_request_binding_is_scope_bound_and_idempotent(postgres_resources) -> None:
|
|
"""callback request 的完整 claim 绑定可复用,改绑和跨 scope 读取都会失败。"""
|
|
resources = postgres_resources
|
|
meme = StorageCoordinator(resources).upload(b"callback-target", target_key="callback-target.png", extension=".png", context={}, provenance={})
|
|
with resources.environment("local") as environment:
|
|
task = environment.tasks.submit(
|
|
task_type="meme_context_generation",
|
|
payload={"meme_id": str(meme.id), "image_sha256": meme.sha256},
|
|
lane="agent",
|
|
dedupe_key=f"callback-{uuid4().hex}",
|
|
)
|
|
claim = environment.tasks.claim(owner="callback-owner", task_id=task.id, lease_seconds=60)
|
|
assert claim is not None
|
|
row = environment.callback_requests.create(
|
|
request_id="callback-request-1",
|
|
task_id=claim.id,
|
|
claim_generation=claim.claim_generation,
|
|
attempt=claim.attempt_count,
|
|
operation="analysis.reverse_image_search",
|
|
target_sha256=meme.sha256,
|
|
input_digest="a" * 64,
|
|
)
|
|
assert row.state == "started"
|
|
same = environment.callback_requests.create(
|
|
request_id="callback-request-1",
|
|
task_id=claim.id,
|
|
claim_generation=claim.claim_generation,
|
|
attempt=claim.attempt_count,
|
|
operation="analysis.reverse_image_search",
|
|
target_sha256=meme.sha256,
|
|
input_digest="a" * 64,
|
|
)
|
|
assert (same.scope_id, same.request_id) == (row.scope_id, row.request_id)
|
|
with pytest.raises(DatabaseError, match="callback_request_conflict"):
|
|
environment.callback_requests.create(
|
|
request_id="callback-request-1",
|
|
task_id=claim.id,
|
|
claim_generation=claim.claim_generation,
|
|
attempt=claim.attempt_count,
|
|
operation="analysis.reverse_image_search",
|
|
target_sha256="b" * 64,
|
|
input_digest="a" * 64,
|
|
)
|
|
environment.callback_requests.finish("callback-request-1", state="unknown_execution", error={"error": "reverse_image_unknown_execution"})
|
|
with resources.environment("local") as environment:
|
|
environment.callback_requests.finish("callback-request-1", state="completed", result={"outcome": "success"})
|
|
final = environment.callback_requests.get("callback-request-1")
|
|
assert final is not None and final.state == "completed"
|
|
|
|
|
|
def test_persistent_operation_grant_is_idempotent_and_does_not_refund_other_operations(postgres_resources) -> None:
|
|
"""PostgreSQL grant 事实按 scope 幂等提交,delete 不会释放 upload reservation。"""
|
|
resources = postgres_resources
|
|
with resources.environment("local") as environment:
|
|
task = environment.tasks.submit(task_type="grant-probe", payload={}, dedupe_key=f"grant-{uuid4().hex}")
|
|
task_id = task.id
|
|
gateway = OperationPolicyGateway(AllowAllOperationPolicy(), allow_all=True)
|
|
from backend.operation_policy import PersistentGrantAssociationStore
|
|
|
|
store = PersistentGrantAssociationStore(resources)
|
|
upload_request = gateway.request("local", Operations.IMAGE_UPLOAD, "upload:grant-probe", task_id=task_id, resource_id=None)
|
|
delete_request = gateway.request("local", Operations.IMAGE_DELETE, "delete:grant-probe", task_id=task_id, resource_id=None)
|
|
upload = store.acquire(upload_request, gateway)
|
|
assert store.acquire(upload_request, gateway).grant == upload.grant
|
|
assert store.transition(upload.grant, "committed") is True
|
|
assert store.transition(upload.grant, "committed") is True
|
|
assert store.transition(upload.grant, "released") is False
|
|
delete = store.acquire(delete_request, gateway)
|
|
assert delete.grant != upload.grant
|
|
with resources.factory() as session:
|
|
rows = list(session.scalars(select(OperationGrant).where(OperationGrant.scope_id == "local")))
|
|
assert {row.operation for row in rows} >= {Operations.IMAGE_UPLOAD, Operations.IMAGE_DELETE}
|
|
|
|
|
|
def test_image_processing_repository_enforces_stage_order_claim_fencing_and_retry(postgres_resources) -> None:
|
|
"""图片 job 固定三阶段、旧 claim 写回拒绝,失败重试只创建新 revision。"""
|
|
resources = postgres_resources
|
|
meme = StorageCoordinator(resources).upload(b"processing-target", target_key="processing-target.png", extension=".png", context={}, provenance={})
|
|
repository = ImageProcessingRepository(resources, "local")
|
|
config = {"agent_model": "test-agent", "embedding_model": "test-embedding", "embedding_dimensions": 1024}
|
|
job = repository.create_or_reuse(meme.id, meme.sha256, config=config, reverse_image_policy="auto")
|
|
assert repository.create_or_reuse(meme.id, meme.sha256, config=config, reverse_image_policy="auto").id == job.id
|
|
with pytest.raises(ImageProcessingError, match="generation_policy_conflict"):
|
|
repository.create_or_reuse(meme.id, meme.sha256, config={**config, "agent_model": "other"}, reverse_image_policy="auto")
|
|
with pytest.raises(ImageProcessingError, match="target_changed"):
|
|
repository.create_or_reuse(meme.id, "f" * 64, config=config, reverse_image_policy="auto")
|
|
|
|
claimed = repository.claim(job.id, owner="processing-owner", lease_seconds=60)
|
|
assert claimed is not None and claimed.claim_generation == 1
|
|
assert repository.transition(job.id, "agent", owner="processing-owner", claim_generation=1, status="running") is False
|
|
assert repository.transition(job.id, "visual", owner="wrong-owner", claim_generation=1, status="running") is False
|
|
assert repository.transition(job.id, "visual", owner="processing-owner", claim_generation=1, status="running") is True
|
|
assert repository.transition(job.id, "visual", owner="processing-owner", claim_generation=1, status="succeeded") is True
|
|
assert repository.transition(job.id, "agent", owner="processing-owner", claim_generation=1, status="running") is True
|
|
assert repository.transition(job.id, "agent", owner="processing-owner", claim_generation=1, status="failed", error={"error": "unknown_execution"}) is True
|
|
snapshot = repository.snapshot(job.id)
|
|
assert snapshot is not None and snapshot.status == "failed" and snapshot.current_stage == "agent"
|
|
retried = repository.retry(job.id, config=config)
|
|
assert retried.id != job.id and retried.revision == 2 and retried.status == "queued"
|
|
|
|
|
|
def test_image_worker_agent_dedupes_before_acquire_and_persists_one_trusted_grant(postgres_resources) -> None:
|
|
"""真实 PostgreSQL Worker 在有效产物/活动任务后不计量,并发只创建一个 grant/Task。"""
|
|
resources = postgres_resources
|
|
policy_impl = _CountingAllowAllPolicy()
|
|
gateway = OperationPolicyGateway(policy_impl, allow_all=True)
|
|
grant_store = PersistentGrantAssociationStore(resources)
|
|
task_service = SimpleNamespace(
|
|
agent_concurrency=1,
|
|
agent_backpressure=32,
|
|
settings_version="integration-test",
|
|
lease_seconds=60,
|
|
max_attempts=3,
|
|
)
|
|
worker = ImageProcessingWorker(
|
|
resources,
|
|
scope_id="local",
|
|
task_service=task_service,
|
|
policy=gateway,
|
|
grant_store=grant_store,
|
|
max_workers=2,
|
|
)
|
|
# 该测试只验证准备/去重边界,不启动叶子 Task 的外部模型执行。
|
|
worker.schedule = lambda _job_id: None
|
|
assert worker._task_runner is not None
|
|
worker._task_runner.schedule = lambda _task_id: None
|
|
repository = worker.jobs
|
|
config = {
|
|
"agent_model": "integration-agent",
|
|
"embedding_model": "integration-embedding",
|
|
"embedding_dimensions": 1024,
|
|
"scope_id": "attacker-scope",
|
|
"user_id": "attacker-user",
|
|
"grant": "forged-grant",
|
|
"resource_id": "attacker-resource",
|
|
"task_id": "attacker-task",
|
|
"session_id": "attacker-session",
|
|
"attempt": 99,
|
|
}
|
|
|
|
def create_job(storage_key: str):
|
|
"""创建一张仅用于测试单 scope Worker 准备路径的图片 job。"""
|
|
meme = StorageCoordinator(resources).upload(b"agent-worker-target", target_key=storage_key, extension=".png", context={}, provenance={})
|
|
return meme, repository.create_or_reuse(meme.id, meme.sha256, config=config, reverse_image_policy="forbid")
|
|
|
|
def advance_visual(job) -> None:
|
|
"""把测试 job 推到 Agent 阶段,保留真实 claim/fencing 条件。"""
|
|
claimed = repository.claim(job.id, owner=worker.owner, lease_seconds=60)
|
|
assert claimed is not None
|
|
assert repository.transition(job.id, "visual", owner=worker.owner, claim_generation=claimed.claim_generation, status="succeeded") is True
|
|
|
|
try:
|
|
artifact_meme, artifact_job = create_job("worker-artifact.png")
|
|
with resources.factory() as session:
|
|
row = session.get(Meme, artifact_meme.id)
|
|
assert row is not None
|
|
row.context_status = "ready"
|
|
row.provenance = {
|
|
"agent_context": {
|
|
"task_id": "previous-agent-task",
|
|
"image_sha256": row.sha256,
|
|
"model": config["agent_model"],
|
|
"reverse_image_policy": "forbid",
|
|
"processing_config_hash": artifact_job.processing_config_hash,
|
|
"completed_at": "2026-08-16T00:00:00+00:00",
|
|
}
|
|
}
|
|
session.commit()
|
|
advance_visual(artifact_job)
|
|
worker._run(str(artifact_job.id))
|
|
artifact_snapshot = repository.snapshot(artifact_job.id)
|
|
assert artifact_snapshot is not None
|
|
artifact_stage = next(item for item in artifact_snapshot.stages if item["stage"] == "agent")
|
|
assert artifact_stage["status"] == "succeeded"
|
|
assert artifact_stage["task_id"] is None
|
|
assert policy_impl.requests == []
|
|
|
|
active_meme, active_job = create_job("worker-active.png")
|
|
active_payload = {
|
|
"job_id": str(active_job.id),
|
|
"job_revision": active_job.revision,
|
|
"meme_id": str(active_meme.id),
|
|
"image_sha256": active_meme.sha256,
|
|
"reverse_image_policy": "forbid",
|
|
"processing_config_hash": active_job.processing_config_hash,
|
|
"stage": "agent",
|
|
"agent_model": config["agent_model"],
|
|
"embedding_model": config["embedding_model"],
|
|
"embedding_dimensions": config["embedding_dimensions"],
|
|
}
|
|
active_task = worker._task_runner.submit("meme_context_generation", active_payload, schedule=False)
|
|
advance_visual(active_job)
|
|
assert worker._prepare_task(active_job, "agent") == active_task.task_id
|
|
assert policy_impl.requests == []
|
|
|
|
concurrent_meme, concurrent_job = create_job("worker-concurrent.png")
|
|
|
|
def prepare_agent_task(_index: int) -> str:
|
|
"""并发执行同一逻辑 Agent Task 的准备阶段。"""
|
|
return worker._prepare_task(concurrent_job, "agent")
|
|
|
|
with ThreadPoolExecutor(max_workers=8) as executor:
|
|
task_ids = list(executor.map(prepare_agent_task, range(32)))
|
|
assert len(set(task_ids)) == 1
|
|
logical_key = f"agent:{concurrent_job.meme_id}:{concurrent_job.image_sha256}:{concurrent_job.processing_config_hash}:forbid:r{concurrent_job.revision}"
|
|
with resources.factory() as session:
|
|
grants = list(
|
|
session.scalars(
|
|
select(OperationGrant).where(
|
|
OperationGrant.scope_id == "local",
|
|
OperationGrant.operation == Operations.ANALYSIS_AGENT,
|
|
OperationGrant.idempotency_key == logical_key,
|
|
)
|
|
)
|
|
)
|
|
tasks = list(
|
|
session.scalars(
|
|
select(Task).where(
|
|
Task.scope_id == "local",
|
|
Task.task_type == "meme_context_generation",
|
|
Task.dedupe_key == worker._task_runner._dedupe("meme_context_generation", active_payload | {
|
|
"job_id": str(concurrent_job.id),
|
|
"job_revision": concurrent_job.revision,
|
|
"meme_id": str(concurrent_meme.id),
|
|
"image_sha256": concurrent_meme.sha256,
|
|
"processing_config_hash": concurrent_job.processing_config_hash,
|
|
}),
|
|
)
|
|
)
|
|
)
|
|
assert len(policy_impl.requests) == 1
|
|
assert policy_impl.requests[0].scope == ScopeContext("local")
|
|
assert policy_impl.requests[0].resource_id == str(concurrent_meme.id)
|
|
assert policy_impl.requests[0].task_id is None
|
|
assert len(grants) == 1
|
|
assert grants[0].resource_id == str(concurrent_meme.id)
|
|
assert grants[0].task_id == task_ids[0]
|
|
assert len(tasks) == 1
|
|
assert tasks[0].id == task_ids[0]
|
|
assert tasks[0].payload["meme_id"] == str(concurrent_meme.id)
|
|
assert "scope_id" not in tasks[0].payload
|
|
assert "user_id" not in tasks[0].payload
|
|
assert "grant" not in tasks[0].payload
|
|
assert "resource_id" not in tasks[0].payload
|
|
forged = gateway.request("local", Operations.ANALYSIS_AGENT, "empty-resource-check", resource_id=None, scope_id="attacker", user_id="attacker", grant="forged")
|
|
assert forged.scope == ScopeContext("local")
|
|
assert forged.resource_id is None
|
|
finally:
|
|
worker.shutdown()
|
|
|
|
|
|
def test_search_migration_state_uses_single_epoch_and_atomic_switch(postgres_resources) -> None:
|
|
"""增量回填只接受当前 epoch,完成后才切换唯一查询来源。"""
|
|
resources = postgres_resources
|
|
with resources.environment("local") as environment:
|
|
state = environment.search.begin_incremental_backfill("migration-test", total_count=2)
|
|
epoch = state.epoch
|
|
assert state.mode == "backfill"
|
|
assert environment.search.record_incremental_backfill(epoch=epoch - 1, completed_count=1, model="migration-test") is False
|
|
assert environment.search.record_incremental_backfill(epoch=epoch, completed_count=1, model="migration-test") is True
|
|
assert environment.search.switch_incremental_only(epoch=epoch, model="migration-test") is False
|
|
assert environment.search.record_incremental_backfill(epoch=epoch, completed_count=2, model="migration-test") is True
|
|
assert environment.search.switch_incremental_only(epoch=epoch, model="migration-test") is True
|
|
assert environment.search.source_mode("migration-test") == "incremental"
|
|
assert environment.search.source_mode("other-model") == "legacy"
|
|
|
|
|
|
def _clean_business_rows(engine: Engine) -> None:
|
|
"""清理集成测试产生的业务行,不触碰 scope、安装标记和迁移版本。"""
|
|
with engine.begin() as connection:
|
|
for table in ("agent_callback_requests", "reverse_image_usage_events", "operation_grants", "image_processing_attempts", "image_processing_stages", "image_processing_jobs", "meme_text_embeddings", "search_migration_states", "task_lane_slots", "task_batch_items", "task_batches", "tasks", "meme_visual_embeddings", "meme_embeddings", "search_heads", "search_generations", "storage_operations", "meme_collection_items", "meme_collections", "memes"):
|
|
connection.execute(text(f"DELETE FROM {table} WHERE scope_id = 'local'" if table not in {"task_lane_slots"} else f"DELETE FROM {table}"))
|
|
|
|
|
|
@pytest.fixture
|
|
def postgres_resources(postgres_engine: Engine, tmp_path: Path):
|
|
"""提供绑定真实 PostgreSQL 的资源和临时文件根目录。"""
|
|
_clean_business_rows(postgres_engine)
|
|
settings = Settings(_env_file=None, database_url=_test_database_url(), data_root=tmp_path / "data", image_root=tmp_path / "images", embedding_api_key="", embedding_base_url="")
|
|
resources = DatabaseResources(postgres_engine, image_root=settings.image_root, data_root=settings.data_root, settings=settings)
|
|
try:
|
|
yield resources
|
|
finally:
|
|
_clean_business_rows(postgres_engine)
|
|
|
|
|
|
def test_storage_recovery_and_two_scope_namespaces(postgres_resources, postgres_engine: Engine, tmp_path: Path) -> None:
|
|
"""验证上传暂存恢复、双 scope 同名路径物理隔离且数据库互不可见。"""
|
|
resources = postgres_resources
|
|
coordinator = StorageCoordinator(resources)
|
|
payload = b"storage-recovery"
|
|
meme = coordinator.upload(payload, target_key="same.png", extension=".png", context={}, provenance={})
|
|
assert resources.blob_store.resolve("same.png").read_bytes() == payload
|
|
with resources.factory() as session:
|
|
operation = session.scalar(select(StorageOperation).where(StorageOperation.meme_id == meme.id))
|
|
assert operation is not None and operation.status == "completed"
|
|
suffix = uuid4().hex
|
|
with postgres_engine.begin() as connection:
|
|
connection.execute(text("INSERT INTO scopes(id, storage_namespace, created_at) VALUES (:id, :namespace, now())"), {"id": f"isolated-{suffix}", "namespace": uuid4()})
|
|
isolated = resources.blob_store_for_scope(f"isolated-{suffix}")
|
|
assert isolated.root != resources.blob_store.root
|
|
assert not isolated.resolve("same.png", must_exist=False).exists()
|
|
isolated_coordinator = StorageCoordinator(resources, scope_id=f"isolated-{suffix}")
|
|
isolated_coordinator.upload(payload, target_key="same.png", extension=".png", context={}, provenance={})
|
|
assert isolated.resolve("same.png").read_bytes() == payload
|
|
assert resources.blob_store.resolve("same.png").read_bytes() == payload
|
|
with resources.environment("local") as environment:
|
|
assert len(environment.memes.list()) == 1
|
|
with resources.environment(f"isolated-{suffix}") as environment:
|
|
assert len(environment.memes.list()) == 1
|
|
with postgres_engine.begin() as connection:
|
|
connection.execute(text("DELETE FROM scopes WHERE id = :id"), {"id": f"isolated-{suffix}"})
|
|
|
|
|
|
def test_collection_export_query_is_ordered_and_excludes_active_storage_operations(postgres_resources) -> None:
|
|
"""合集导出查询保持成员顺序,并排除 prepared 存储中间态 Meme。"""
|
|
coordinator = StorageCoordinator(postgres_resources)
|
|
first = coordinator.upload(b"first", target_key="first.png", extension=".png", context={}, provenance={})
|
|
second = coordinator.upload(b"second", target_key="second.png", extension=".png", context={}, provenance={})
|
|
with postgres_resources.environment("local") as environment:
|
|
collection = environment.collections.create("导出查询")
|
|
environment.collections.add_members(collection.id, [first.id, second.id])
|
|
with postgres_resources.factory() as session:
|
|
session.add(StorageOperation(scope_id="local", meme_id=second.id, operation_type="rename", operation_token=uuid4(), source_key="second.png", target_key="second-new.png", before_sha256=second.sha256, after_sha256=second.sha256, before_size=second.size_bytes, after_size=second.size_bytes, status="prepared"))
|
|
session.commit()
|
|
with postgres_resources.environment("local") as environment:
|
|
exported = environment.collections.members_for_export(collection.id)
|
|
assert [item.id for item in exported] == [first.id]
|
|
|
|
|
|
def test_task_claim_fencing_and_global_lane_capacity(postgres_resources) -> None:
|
|
"""两个独立连接认领同一任务时只允许一个成功,旧 claim 不能写回。"""
|
|
from backend.database import TaskRepository
|
|
|
|
engine = postgres_resources.engine
|
|
with postgres_resources.environment("local") as environment:
|
|
task = environment.tasks.submit(task_type="fencing", payload={}, lane="agent", dedupe_key=f"fencing-{uuid4().hex}")
|
|
task_id = task.id
|
|
barrier = threading.Barrier(2)
|
|
claims: list[tuple[str, int] | None] = []
|
|
|
|
def claim(owner: str) -> None:
|
|
with postgres_resources.environment("local") as environment:
|
|
barrier.wait()
|
|
claimed = environment.tasks.claim(owner=owner, lane="agent", lane_capacity=1, lease_seconds=30, task_id=task_id)
|
|
claims.append((claimed.id, claimed.claim_generation) if claimed else None)
|
|
|
|
first = threading.Thread(target=claim, args=("worker-a",))
|
|
second = threading.Thread(target=claim, args=("worker-b",))
|
|
first.start(); second.start(); first.join(); second.join()
|
|
assert sum(item is not None for item in claims) == 1
|
|
active = next(item for item in claims if item is not None)
|
|
with postgres_resources.environment("local") as environment:
|
|
assert environment.tasks.update_fenced(task_id, active[1] - 1, "worker-a", status="succeeded") is False
|
|
assert environment.tasks.update_fenced(task_id, active[1], "worker-a" if active[0] else "worker-b", status="succeeded") in {True, False}
|
|
|
|
|
|
def test_process_worker_manager_handles_many_scopes_and_restart_claims(postgres_resources, postgres_engine: Engine) -> None:
|
|
"""进程级 manager 跨两个 scope 恢复任务,handler 使用持久 scope 且不复制 Worker。"""
|
|
suffix = uuid4().hex
|
|
scope_ids = [f"manager-a-{suffix}", f"manager-b-{suffix}"]
|
|
with postgres_engine.begin() as connection:
|
|
for scope_id in scope_ids:
|
|
connection.execute(text("INSERT INTO scopes(id, storage_namespace, created_at) VALUES (:id, :namespace, now())"), {"id": scope_id, "namespace": uuid4()})
|
|
settings = Settings(_env_file=None, database_url=_test_database_url(), data_root=postgres_resources.data_root, image_root=postgres_resources.image_root, embedding_api_key="", embedding_base_url="")
|
|
manager = PostgresTaskWorkerManager(postgres_resources, agent_concurrency=1, max_attempts=2)
|
|
factory = ScopeServiceFactory(postgres_resources, settings, worker_manager=manager)
|
|
observed: list[str] = []
|
|
observed_lock = threading.Lock()
|
|
completed = threading.Event()
|
|
|
|
def handler(payload, _progress):
|
|
"""记录 claim 注入的持久 scope,模拟可并发的短任务。"""
|
|
with observed_lock:
|
|
observed.append(str(payload["_claim_scope_id"]))
|
|
if len(observed) == len(scope_ids):
|
|
completed.set()
|
|
return {"scope": payload["_claim_scope_id"]}
|
|
|
|
manager.register("manager_probe", handler)
|
|
try:
|
|
for index, scope_id in enumerate(scope_ids):
|
|
service = factory.for_scope(scope_id).tasks
|
|
service.submit("manager_probe", {"index": index}, schedule=False)
|
|
with postgres_resources.factory() as session:
|
|
first = session.scalar(select(Task).where(Task.scope_id == scope_ids[0]))
|
|
assert first is not None
|
|
first.status = "running"
|
|
first.lease_owner = "crashed-worker"
|
|
first.lease_expires_at = utcnow().replace(year=2000)
|
|
first.attempt_count = 1
|
|
session.commit()
|
|
manager.start()
|
|
assert completed.wait(10)
|
|
assert sorted(observed) == sorted(scope_ids)
|
|
assert manager.worker_count == 1
|
|
assert len(factory._services) == 0
|
|
# handler 返回与 fenced 状态写回不是同一个事务;等待持久终态,避免读取到
|
|
# handler 已执行但任务仍处于 running 的合法短暂窗口。
|
|
deadline = time.monotonic() + 10
|
|
rows: list[Task] = []
|
|
while time.monotonic() < deadline:
|
|
with postgres_resources.factory() as session:
|
|
rows = list(session.scalars(select(Task).where(Task.scope_id.in_(scope_ids)).order_by(Task.scope_id)))
|
|
statuses = [row.status for row in rows]
|
|
if statuses == ["succeeded", "succeeded"]:
|
|
break
|
|
time.sleep(0.05)
|
|
assert [row.status for row in rows] == ["succeeded", "succeeded"]
|
|
assert all(row.error is None or row.error.get("error") != "task_scope_mismatch" for row in rows)
|
|
finally:
|
|
factory.shutdown()
|
|
with postgres_engine.begin() as connection:
|
|
connection.execute(text("DELETE FROM scopes WHERE id IN (:scope_a, :scope_b)"), {"scope_a": scope_ids[0], "scope_b": scope_ids[1]})
|
|
|
|
|
|
def test_host_database_resources_do_not_require_local_scope(postgres_engine: Engine, tmp_path: Path) -> None:
|
|
"""宿主资源装配允许数据库只有宿主 scope,不创建 local BlobStore。"""
|
|
scope_id = f"host-only-{uuid4().hex}"
|
|
with postgres_engine.begin() as connection:
|
|
connection.execute(text("DELETE FROM scopes WHERE id = 'local'"))
|
|
connection.execute(text("INSERT INTO scopes(id, storage_namespace, created_at) VALUES (:id, :namespace, now())"), {"id": scope_id, "namespace": uuid4()})
|
|
try:
|
|
settings = Settings(_env_file=None, database_url=_test_database_url(), data_root=tmp_path / "data", image_root=tmp_path / "images")
|
|
resources = DatabaseResources(postgres_engine, image_root=settings.image_root, data_root=settings.data_root, settings=settings, require_local_scope=False)
|
|
assert resources.blob_store is None
|
|
assert resources.blob_store_for_scope(scope_id).scope == ScopeContext(scope_id)
|
|
finally:
|
|
with postgres_engine.begin() as connection:
|
|
connection.execute(text("DELETE FROM scopes WHERE id = :id"), {"id": scope_id})
|
|
connection.execute(text("INSERT INTO scopes(id, storage_namespace, created_at) VALUES ('local', :namespace, now())"), {"namespace": uuid4()})
|
|
|
|
|
|
def test_sealed_batch_reopens_after_late_agent_child(postgres_resources) -> None:
|
|
"""视觉重试在旧缓存收束后补入 Agent 时,批次必须再次提交文本索引。"""
|
|
batch_id = f"late-agent-{uuid4().hex}"
|
|
with postgres_resources.environment("local") as environment:
|
|
visual = environment.tasks.submit(
|
|
task_type="visual_embedding_generation",
|
|
payload={"meme_id": str(uuid4()), "image_sha256": "a" * 64},
|
|
dedupe_key=f"visual-{uuid4().hex}",
|
|
)
|
|
environment.tasks.add_batch_item(batch_id, visual.id)
|
|
environment.tasks.seal_batch(batch_id)
|
|
visual.status = "succeeded"
|
|
first_cache = environment.tasks.finalize_batch_with_task(
|
|
batch_id,
|
|
task_type="cache_generation",
|
|
payload={},
|
|
dedupe_key="cache_generation",
|
|
)
|
|
assert first_cache is not None
|
|
first_cache.status = "succeeded"
|
|
|
|
child = environment.tasks.submit(
|
|
task_type="meme_context_generation",
|
|
payload={"meme_id": str(uuid4()), "image_sha256": "b" * 64},
|
|
lane="agent",
|
|
dedupe_key=f"context-{uuid4().hex}",
|
|
)
|
|
environment.tasks.add_batch_item(batch_id, child.id)
|
|
batch = environment.uow.session.get(TaskBatch, {"scope_id": "local", "batch_id": batch_id})
|
|
assert batch is not None and batch.finalizer_state == "pending"
|
|
child.status = "succeeded"
|
|
second_cache = environment.tasks.finalize_batch_with_task(
|
|
batch_id,
|
|
task_type="cache_generation",
|
|
payload={},
|
|
dedupe_key="cache_generation",
|
|
)
|
|
assert second_cache is not None and second_cache.id != first_cache.id
|
|
|
|
|
|
def test_sealed_batch_accepts_visual_stage_retry(postgres_resources) -> None:
|
|
"""视觉阶段显式重试不能因批次已封口而丢失文本索引收束关系。"""
|
|
batch_id = f"visual-retry-{uuid4().hex}"
|
|
with postgres_resources.environment("local") as environment:
|
|
original = environment.tasks.submit(
|
|
task_type="visual_embedding_generation",
|
|
payload={"meme_id": str(uuid4()), "image_sha256": "a" * 64},
|
|
dedupe_key=f"visual-{uuid4().hex}",
|
|
)
|
|
environment.tasks.add_batch_item(batch_id, original.id)
|
|
environment.tasks.seal_batch(batch_id)
|
|
original.status = "failed"
|
|
first_cache = environment.tasks.finalize_batch_with_task(
|
|
batch_id,
|
|
task_type="cache_generation",
|
|
payload={},
|
|
dedupe_key="cache-generation-visual-retry-first",
|
|
)
|
|
assert first_cache is not None
|
|
first_cache.status = "succeeded"
|
|
|
|
retry = environment.tasks.submit(
|
|
task_type="visual_embedding_generation",
|
|
payload={"meme_id": str(uuid4()), "image_sha256": "b" * 64},
|
|
dedupe_key=f"visual-{uuid4().hex}",
|
|
)
|
|
environment.tasks.add_batch_item(batch_id, retry.id)
|
|
batch = environment.uow.session.get(TaskBatch, {"scope_id": "local", "batch_id": batch_id})
|
|
assert batch is not None and batch.finalizer_state == "pending"
|
|
retry.status = "succeeded"
|
|
second_cache = environment.tasks.finalize_batch_with_task(
|
|
batch_id,
|
|
task_type="cache_generation",
|
|
payload={},
|
|
dedupe_key="cache-generation-visual-retry-second",
|
|
)
|
|
assert second_cache is not None and second_cache.id != first_cache.id
|
|
|
|
|
|
def test_generation_activation_rejects_new_indexable_source(postgres_resources) -> None:
|
|
"""generation 激活前必须检测快照漏掉的新可索引 Meme。"""
|
|
from backend.database import Meme, SearchGeneration
|
|
|
|
with postgres_resources.environment("local") as environment:
|
|
first = Meme(scope_id="local", storage_key="first.png", extension=".png", size_bytes=1, sha256="1" * 64, context_status="ready", meme_context={"title": "first", "summary": "x"}, provenance={}, extensions={})
|
|
environment.uow.session.add(first)
|
|
environment.uow.session.flush()
|
|
generation = environment.search.create_generation("test", "a" * 64)
|
|
environment.search.add_snapshot_item(generation.id, meme_id=first.id, meme_revision=first.revision, image_sha256=first.sha256, semantic_document="标题:first", metadata_hash="b" * 64)
|
|
environment.search.set_item_embedding(generation.id, first.id, [1.0] + [0.0] * 1023)
|
|
with postgres_resources.environment("local") as environment:
|
|
second = Meme(scope_id="local", storage_key="second.png", extension=".png", size_bytes=1, sha256="2" * 64, context_status="ready", meme_context={"title": "second", "summary": "x"}, provenance={}, extensions={})
|
|
environment.uow.session.add(second)
|
|
with postgres_resources.environment("local") as environment:
|
|
with pytest.raises(Exception, match="source_changed"):
|
|
environment.search.activate(generation)
|
|
|
|
|
|
def _agent_ready_provenance(meme: Meme) -> dict[str, object]:
|
|
"""构造与当前 Meme SHA 绑定的最小 research provenance。"""
|
|
return {
|
|
"agent_context": {
|
|
"task_id": f"agent-{meme.id}",
|
|
"image_sha256": meme.sha256,
|
|
"model": "test-agent",
|
|
"skill_hash": "skill-hash",
|
|
"completed_at": utcnow().isoformat(),
|
|
}
|
|
}
|
|
|
|
|
|
def test_visual_repository_filters_sha_storage_readiness_and_scope(postgres_resources, postgres_engine: Engine) -> None:
|
|
"""视觉查询只返回当前 scope、当前 SHA 且已完成 Agent 研究的候选。"""
|
|
resources = postgres_resources
|
|
coordinator = StorageCoordinator(resources)
|
|
query = coordinator.upload(b"query", target_key="visual-query.png", extension=".png", context={}, provenance={})
|
|
ready = coordinator.upload(b"ready", target_key="visual-ready.png", extension=".png", context={}, provenance={})
|
|
pending = coordinator.upload(b"pending", target_key="visual-pending.png", extension=".png", context={}, provenance={})
|
|
stale = coordinator.upload(b"stale", target_key="visual-stale.png", extension=".png", context={}, provenance={})
|
|
identity = {"model": "test-visual", "preprocess_version": "test-v1", "dimensions": VISUAL_EMBEDDING_DIMENSIONS}
|
|
search_settings = Settings(_env_file=None, database_url=_test_database_url(), data_root=resources.data_root, image_root=resources.image_root, visual_model="test-visual", visual_model_dimensions=VISUAL_EMBEDDING_DIMENSIONS, visual_preprocess_version="test-v1")
|
|
with resources.environment("local") as environment:
|
|
query_row = environment.memes.get(query.id, for_update=True)
|
|
ready_row = environment.memes.get(ready.id, for_update=True)
|
|
pending_row = environment.memes.get(pending.id, for_update=True)
|
|
stale_row = environment.memes.get(stale.id, for_update=True)
|
|
assert query_row and ready_row and pending_row and stale_row
|
|
for row in (query_row, ready_row, pending_row, stale_row):
|
|
row.context_status = "ready"
|
|
pending_row.context_status = "pending"
|
|
ready_row.provenance = _agent_ready_provenance(ready_row)
|
|
query_row.provenance = _agent_ready_provenance(query_row)
|
|
stale_row.provenance = _agent_ready_provenance(stale_row)
|
|
environment.visual.upsert(query_row.id, **identity, image_sha256=query_row.sha256, embedding=[1.0] + [0.0] * (VISUAL_EMBEDDING_DIMENSIONS - 1))
|
|
environment.visual.upsert(ready_row.id, **identity, image_sha256=ready_row.sha256, embedding=[1.0] + [0.0] * (VISUAL_EMBEDDING_DIMENSIONS - 1))
|
|
environment.visual.upsert(pending_row.id, **identity, image_sha256=pending_row.sha256, embedding=[1.0] + [0.0] * (VISUAL_EMBEDDING_DIMENSIONS - 1))
|
|
environment.visual.upsert(stale_row.id, **identity, image_sha256=stale_row.sha256, embedding=[1.0] + [0.0] * (VISUAL_EMBEDDING_DIMENSIONS - 1))
|
|
with pytest.raises(DatabaseError, match="visual_embedding_sha256_mismatch"):
|
|
environment.visual.upsert(query_row.id, **identity, image_sha256="f" * 64, embedding=[1.0] + [0.0] * (VISUAL_EMBEDDING_DIMENSIONS - 1))
|
|
task = environment.tasks.submit(task_type="meme_context_generation", payload={"meme_id": str(query.id), "image_sha256": query.sha256, "visual_model": identity["model"], "visual_dimensions": identity["dimensions"], "preprocess_version": identity["preprocess_version"]}, lane="agent", dedupe_key=f"visual-filter-{uuid4().hex}")
|
|
task.status = "running"
|
|
task_id = task.id
|
|
resources.blob_store.resolve(stale.storage_key).write_bytes(b"stale-changed")
|
|
result = VisualSearchService(search_settings, resources).match(task_id=task_id, top_k=10)
|
|
assert [item["meme_id"] for item in result["results"]] == [str(ready.id)]
|
|
other_scope = f"visual-isolated-{uuid4().hex}"
|
|
with postgres_engine.begin() as connection:
|
|
connection.execute(text("INSERT INTO scopes(id, storage_namespace, created_at) VALUES (:id, :namespace, now())"), {"id": other_scope, "namespace": uuid4()})
|
|
isolated = StorageCoordinator(resources, scope_id=other_scope).upload(b"other", target_key="visual-other.png", extension=".png", context={}, provenance={})
|
|
with resources.environment(other_scope) as environment:
|
|
other_row = environment.memes.get(isolated.id, for_update=True)
|
|
assert other_row is not None
|
|
other_row.context_status = "ready"
|
|
other_row.provenance = _agent_ready_provenance(other_row)
|
|
environment.visual.upsert(other_row.id, **identity, image_sha256=other_row.sha256, embedding=[1.0] + [0.0] * (VISUAL_EMBEDDING_DIMENSIONS - 1))
|
|
with resources.environment("local") as environment:
|
|
assert all(row[1].id != isolated.id for row in environment.visual.match([1.0] + [0.0] * (VISUAL_EMBEDDING_DIMENSIONS - 1), **identity, limit=10))
|
|
with postgres_engine.begin() as connection:
|
|
connection.execute(text("DELETE FROM scopes WHERE id = :id"), {"id": other_scope})
|
|
|
|
|
|
def test_visual_embedding_lifecycle_follows_rename_and_delete(postgres_resources) -> None:
|
|
"""重命名保留同一视觉产物,删除通过复合外键级联清理向量。"""
|
|
resources = postgres_resources
|
|
coordinator = StorageCoordinator(resources)
|
|
meme = coordinator.upload(b"lifecycle", target_key="visual-lifecycle.png", extension=".png", context={}, provenance={})
|
|
identity = {"model": "test-visual", "preprocess_version": "test-v1", "dimensions": VISUAL_EMBEDDING_DIMENSIONS}
|
|
with resources.environment("local") as environment:
|
|
row = environment.memes.get(meme.id, for_update=True)
|
|
assert row is not None
|
|
environment.visual.upsert(row.id, **identity, image_sha256=row.sha256, embedding=[1.0] + [0.0] * (VISUAL_EMBEDDING_DIMENSIONS - 1))
|
|
coordinator.rename(meme.id, target_key="visual-lifecycle-renamed.png")
|
|
with resources.environment("local") as environment:
|
|
assert environment.visual.get(meme.id, **identity) is not None
|
|
coordinator.delete(meme.id)
|
|
with resources.factory() as session:
|
|
assert session.scalar(select(MemeVisualEmbedding).where(MemeVisualEmbedding.meme_id == meme.id)) is None
|
|
|
|
|
|
def test_visual_search_requires_running_task_and_returns_structured_candidates(postgres_resources) -> None:
|
|
"""内部视觉匹配只接受 running 任务,并返回带语境和媒体引用的候选。"""
|
|
resources = postgres_resources
|
|
settings = Settings(_env_file=None, database_url=_test_database_url(), data_root=resources.data_root, image_root=resources.image_root)
|
|
coordinator = StorageCoordinator(resources)
|
|
query = coordinator.upload(b"search-query", target_key="visual-search-query.png", extension=".png", context={"title": "Query", "summary": "query"}, provenance={})
|
|
candidate = coordinator.upload(b"search-candidate", target_key="visual-search-candidate.png", extension=".png", context={"title": "Candidate", "summary": "candidate"}, provenance={})
|
|
identity = {"model": settings.visual_model, "preprocess_version": settings.visual_preprocess_version, "dimensions": settings.visual_model_dimensions}
|
|
with resources.environment("local") as environment:
|
|
query_row = environment.memes.get(query.id, for_update=True)
|
|
candidate_row = environment.memes.get(candidate.id, for_update=True)
|
|
assert query_row and candidate_row
|
|
query_row.context_status = candidate_row.context_status = "ready"
|
|
query_row.provenance = _agent_ready_provenance(query_row)
|
|
candidate_row.provenance = _agent_ready_provenance(candidate_row)
|
|
environment.visual.upsert(query_row.id, **identity, image_sha256=query_row.sha256, embedding=[1.0] + [0.0] * (VISUAL_EMBEDDING_DIMENSIONS - 1))
|
|
environment.visual.upsert(candidate_row.id, **identity, image_sha256=candidate_row.sha256, embedding=[1.0] + [0.0] * (VISUAL_EMBEDDING_DIMENSIONS - 1))
|
|
task = environment.tasks.submit(task_type="meme_context_generation", payload={"meme_id": str(query.id), "image_sha256": query.sha256, **{"visual_model": identity["model"], "visual_dimensions": identity["dimensions"], "preprocess_version": identity["preprocess_version"]}}, lane="agent", dedupe_key=f"visual-search-{uuid4().hex}")
|
|
task_id = task.id
|
|
service = VisualSearchService(settings, resources)
|
|
with pytest.raises(VisualSearchError) as error:
|
|
service.match(task_id=task_id)
|
|
assert error.value.code == "task_not_running"
|
|
with resources.environment("local") as environment:
|
|
task = environment.tasks.get(task_id, for_update=True)
|
|
assert task is not None
|
|
task.status = "running"
|
|
result = service.match(task_id=task_id, top_k=10)
|
|
assert [item["meme_id"] for item in result["results"]] == [str(candidate.id)]
|
|
assert result["results"][0]["media_url"] == f"/media/{candidate.id}"
|
|
assert result["results"][0]["context"]["title"] == "Candidate"
|