feat(skills/notifications): 多租户阶段 4 — 测试 + 前端
后端:
tests/test_skills_multitenancy.py(12 个用例,全部通过)
- SkillStore: 可见性 / 全局重名拒绝 / owner-only update+delete /
admin override / ensure_legacy 仅补 NULL owner
- 路由助手 _get_editable_skill_or_raise / _get_owned_skill_or_raise
的 owner / admin / 403 / 404 分流
- cascade_skill_unpublished_or_deleted:
· unpublish 同时扫两个用户的 agent,剥掉 skill;自己改自己不发
通知,其他人收 agent_skill_removed
· admin_takedown 额外给 skill owner 发 skill_taken_down
- NotificationStore CRUD + unread_count + 跨用户隔离
- 同步版 apply_automatic_transitions 用户隔离:A 跑 curator 不
动 B 的 skill;curator_log per-user 分桶
前端 — core/notifications/{types,api,hooks,index}.ts
- 类型对齐 routers/notifications.py 的 NotificationItem
- listNotifications / getUnreadCount / markRead / markAllRead /
deleteNotification
- useNotifications / useUnreadCount(refetchInterval=30s) / mutation 钩子
前端 — components/workspace/notification-bell.tsx
- 顶部铃铛图标 + 未读红点(>99 显示 99+)
- 复用 DropdownMenu 作为弹层(项目暂无 Popover 原语)
- 列表项:未读蓝点 + 标题 + body + 相对时间,悬停露出删除按钮
- 标记单条已读 / 全部已读 / 删除
- 接入 workspace-nav-menu.tsx,作为 SidebarMenuItem 放在设置入口上方
前端 — Skill 多租户字段
- core/skills/type.ts 加 owner_user_id / published / builtin
- core/skills/api.ts 加 setSkillPublished + deleteCustomSkill
- core/skills/hooks.ts 加 useSetSkillPublished + useDeleteCustomSkill
前端 — settings/skill-settings-page.tsx
- 抽出 SkillRow 子组件,每项渲染 owner badge(内置/我的/来自他人/
已发布),样式抄自 agent-card.tsx 的 isBuiltin/isOwned/isFromOthers
- canManage = isOwned || isAdmin;canTogglePublish 同上但排除内置
- 发布/取消发布按钮(Eye / EyeOff),调用 setSkillPublished
- 删除按钮 + 二次确认 Dialog,调用 deleteCustomSkill
- 启用开关保持原样
前端 — i18n
- types.ts: 新增 NotificationsTranslations + Translations.notifications;
settings.skills 加 badgeBuiltin/badgeMine/badgeFromOthers(+Title) /
badgePublished(+Title) / publishAction / unpublishAction /
publishSuccess / unpublishSuccess / deleteAction / deleteConfirm /
deleteSuccess
- zh-CN.ts / en-US.ts 同步补齐对应文案
验证
- PYTHONPATH=. uv run pytest tests/test_skills_multitenancy.py -v →
12 passed in 2.08s
- 与现有 test_agents_backend / test_skill_evolution_runtime /
test_skills_install_upload 并行跑:41 通过 + 1 个 pre-existing 失败
(test_validate_rejects_missing_skill_md 期望 "SKILL.md" 但收到
"Skill archive is empty",与本期改动无关)
- pnpm typecheck:本次新增/修改的文件零新增错误;剩余报错全部为
pre-existing 的 next/link、next/navigation 别名声明缺失、
workspace-nav-menu.tsx 的 SettingsSection 类型不匹配、nextra
缺失、markdown-docx 第三方库等
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
4ef96c7930
commit
99febda256
14 files changed
+1133
-21
No files matched your search
@@ -0,0 +1,432 @@
|
||||
"""Multitenancy tests for the Stage 1-3 skills/notifications work.
|
||||
|
||||
Covers:
|
||||
- ``SkillStore`` per-user visibility, global-name uniqueness, admin override
|
||||
- ``_get_editable_skill_or_raise`` route helper (owner / admin / 403 / 404 paths)
|
||||
- ``cascade_skill_unpublished_or_deleted`` removes skills from referencing
|
||||
agents' ``config.yaml`` and writes notifications via the sender shim
|
||||
- ``NotificationStore`` CRUD + ``unread_count``
|
||||
- Curator per-user isolation — user A's transitions never touch user B's skills
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.gateway.routers.skills import _get_editable_skill_or_raise, _get_owned_skill_or_raise
|
||||
from deerflow.config import paths as paths_module
|
||||
from deerflow.persistence.agents import MemoryAgentStore
|
||||
from deerflow.persistence.notifications import MemoryNotificationStore, set_notification_store
|
||||
from deerflow.persistence.skills import MemorySkillStore
|
||||
from deerflow.skills import curator_log
|
||||
from deerflow.skills.cascade import cascade_skill_unpublished_or_deleted
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def isolated_paths(tmp_path, monkeypatch):
|
||||
"""Redirect get_paths() at a fresh temp directory for the duration of one test."""
|
||||
monkeypatch.setenv("DEER_FLOW_HOME", str(tmp_path))
|
||||
monkeypatch.setattr(paths_module, "_paths", None)
|
||||
yield tmp_path
|
||||
monkeypatch.setattr(paths_module, "_paths", None)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def notification_store():
|
||||
"""Install a fresh ``MemoryNotificationStore`` as the sender backend.
|
||||
|
||||
Cleared on both setup and teardown so a stale store from a previous test
|
||||
can never leak in through the global ``_store`` singleton in
|
||||
``deerflow.persistence.notifications.sender``.
|
||||
"""
|
||||
store = MemoryNotificationStore()
|
||||
set_notification_store(store)
|
||||
yield store
|
||||
set_notification_store(None)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SkillStore: per-user visibility
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_skill_store_visibility_owner_published_and_builtin() -> None:
|
||||
store = MemorySkillStore()
|
||||
|
||||
# Builtin (legacy migrated, no human owner) — visible to everyone.
|
||||
await store.ensure_legacy({"name": "builtin-skill", "owner_user_id": None, "published": True})
|
||||
# User A's private skill (not published).
|
||||
await store.create({"name": "alpha-tool", "owner_user_id": "user-a", "published": False})
|
||||
# User B's published skill — visible across users.
|
||||
await store.create({"name": "beta-tool", "owner_user_id": "user-b", "published": True})
|
||||
# User B's private skill — should be invisible to A and C.
|
||||
await store.create({"name": "gamma-tool", "owner_user_id": "user-b", "published": False})
|
||||
|
||||
names_for_a = {s["name"] for s in await store.list_visible("user-a")}
|
||||
assert names_for_a == {"builtin-skill", "alpha-tool", "beta-tool"}
|
||||
|
||||
names_for_c = {s["name"] for s in await store.list_visible("user-c")}
|
||||
assert names_for_c == {"builtin-skill", "beta-tool"}
|
||||
|
||||
# get_visible respects the same filter.
|
||||
assert (await store.get_visible("alpha-tool", "user-a")) is not None
|
||||
assert (await store.get_visible("alpha-tool", "user-c")) is None
|
||||
assert (await store.get_visible("gamma-tool", "user-a")) is None
|
||||
|
||||
# builtin flag is derived from owner_user_id IS NULL.
|
||||
builtin = await store.get_any("builtin-skill")
|
||||
assert builtin is not None and builtin["builtin"] is True
|
||||
alpha = await store.get_any("alpha-tool")
|
||||
assert alpha is not None and alpha["builtin"] is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_skill_store_create_rejects_duplicate_name() -> None:
|
||||
"""Global uniqueness — even an admin can't register a name a normal user owns."""
|
||||
store = MemorySkillStore()
|
||||
await store.create({"name": "shared", "owner_user_id": "user-a"})
|
||||
|
||||
with pytest.raises(ValueError, match="already exists"):
|
||||
await store.create({"name": "shared", "owner_user_id": "user-b"})
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_skill_store_only_owner_can_update_or_delete() -> None:
|
||||
store = MemorySkillStore()
|
||||
await store.create({"name": "alpha-tool", "owner_user_id": "user-a", "published": False})
|
||||
|
||||
# Non-owner update returns None and does not mutate.
|
||||
assert await store.update("alpha-tool", "user-b", {"published": True}) is None
|
||||
record = await store.get_any("alpha-tool")
|
||||
assert record is not None and record["published"] is False
|
||||
|
||||
# Owner can update.
|
||||
updated = await store.update("alpha-tool", "user-a", {"published": True})
|
||||
assert updated is not None and updated["published"] is True
|
||||
|
||||
# Non-owner delete fails.
|
||||
assert await store.delete("alpha-tool", "user-b") is False
|
||||
assert await store.get_any("alpha-tool") is not None
|
||||
|
||||
# Owner delete succeeds.
|
||||
assert await store.delete("alpha-tool", "user-a") is True
|
||||
assert await store.get_any("alpha-tool") is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_skill_store_admin_override_can_unpublish_and_delete_any_skill() -> None:
|
||||
"""``update_admin`` / ``delete_admin`` bypass ownership — including others' owned skills."""
|
||||
store = MemorySkillStore()
|
||||
await store.create({"name": "alpha-tool", "owner_user_id": "user-a", "published": True})
|
||||
|
||||
# update_admin can flip published on a skill the admin doesn't own.
|
||||
updated = await store.update_admin("alpha-tool", {"published": False})
|
||||
assert updated is not None and updated["published"] is False
|
||||
|
||||
# delete_admin removes it.
|
||||
assert await store.delete_admin("alpha-tool") is True
|
||||
assert await store.get_any("alpha-tool") is None
|
||||
|
||||
# And no-ops on a missing name.
|
||||
assert await store.update_admin("does-not-exist", {"published": False}) is None
|
||||
assert await store.delete_admin("does-not-exist") is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_skill_store_ensure_legacy_patches_null_owner_only() -> None:
|
||||
store = MemorySkillStore()
|
||||
# First migration: no owner → claim by admin.
|
||||
first = await store.ensure_legacy({"name": "legacy-skill", "owner_user_id": "admin-id", "published": True})
|
||||
assert first["owner_user_id"] == "admin-id"
|
||||
|
||||
# Second migration: someone else tries to take ownership — refused, original wins.
|
||||
second = await store.ensure_legacy({"name": "legacy-skill", "owner_user_id": "someone-else"})
|
||||
assert second["owner_user_id"] == "admin-id"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Route helper authorization (mirrors test_agents_backend.py contract)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_editable_skill_or_raise_owner_admin_and_403() -> None:
|
||||
store = MemorySkillStore()
|
||||
await store.ensure_legacy({"name": "builtin-skill", "owner_user_id": None, "published": True})
|
||||
await store.create({"name": "owned", "owner_user_id": "user-a", "published": False})
|
||||
await store.create({"name": "others-published", "owner_user_id": "user-b", "published": True})
|
||||
|
||||
# Owner can edit own skill.
|
||||
record, mode = await _get_editable_skill_or_raise(store, "owned", "user-a", is_admin=False)
|
||||
assert mode == "owner" and record["name"] == "owned"
|
||||
|
||||
# Admin can edit a built-in (NULL owner).
|
||||
record, mode = await _get_editable_skill_or_raise(store, "builtin-skill", "user-a", is_admin=True)
|
||||
assert mode == "admin" and record["name"] == "builtin-skill"
|
||||
|
||||
# Unlike agents, the spec lets admin take down ANY skill, including
|
||||
# another user's published one.
|
||||
record, mode = await _get_editable_skill_or_raise(store, "others-published", "user-a", is_admin=True)
|
||||
assert mode == "admin" and record["owner_user_id"] == "user-b"
|
||||
|
||||
# Non-admin trying to edit a built-in → 403.
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await _get_editable_skill_or_raise(store, "builtin-skill", "user-a", is_admin=False)
|
||||
assert exc.value.status_code == 403
|
||||
|
||||
# Non-admin trying to edit someone else's published skill → 403.
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await _get_editable_skill_or_raise(store, "others-published", "user-a", is_admin=False)
|
||||
assert exc.value.status_code == 403
|
||||
|
||||
# Missing skill → 404 regardless of admin.
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await _get_editable_skill_or_raise(store, "missing", "user-a", is_admin=True)
|
||||
assert exc.value.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_owned_skill_or_raise_distinguishes_404_from_403() -> None:
|
||||
"""Visible-but-not-owned must surface as 403 so the user sees a clear reason."""
|
||||
store = MemorySkillStore()
|
||||
await store.create({"name": "published-from-b", "owner_user_id": "user-b", "published": True})
|
||||
|
||||
# Owner check on a published-but-not-owned skill returns 403, not 404.
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await _get_owned_skill_or_raise(store, "published-from-b", "user-a")
|
||||
assert exc.value.status_code == 403
|
||||
|
||||
# Truly invisible / nonexistent → 404.
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await _get_owned_skill_or_raise(store, "nope", "user-a")
|
||||
assert exc.value.status_code == 404
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cascade: detaches references + writes notifications
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _write_agent_dir(base_dir, *, agent_id: str, skills: list[str]) -> None:
|
||||
agent_dir = base_dir / "agents" / agent_id
|
||||
agent_dir.mkdir(parents=True, exist_ok=True)
|
||||
(agent_dir / "config.yaml").write_text(
|
||||
yaml.safe_dump(
|
||||
{"id": agent_id, "name": agent_id, "description": "", "skills": skills},
|
||||
default_flow_style=False,
|
||||
allow_unicode=True,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cascade_unpublish_strips_skill_and_notifies_other_owners(
|
||||
isolated_paths, notification_store
|
||||
) -> None:
|
||||
"""User A unpublishes ``alpha-tool``. User B's agent loses the skill from its
|
||||
config and gets an in-app notification. User A's own agent loses the skill
|
||||
too but no notification (self action)."""
|
||||
agent_store = MemoryAgentStore()
|
||||
# User A is the actor; she also owns one of the referencing agents.
|
||||
await agent_store.create({"id": "agent-of-a", "name": "Agent A", "user_id": "user-a"})
|
||||
await agent_store.create({"id": "agent-of-b", "name": "Agent B", "user_id": "user-b"})
|
||||
# An agent referencing a different skill — should be untouched.
|
||||
await agent_store.create({"id": "agent-untouched", "name": "Untouched", "user_id": "user-b"})
|
||||
|
||||
_write_agent_dir(isolated_paths, agent_id="agent-of-a", skills=["alpha-tool", "other"])
|
||||
_write_agent_dir(isolated_paths, agent_id="agent-of-b", skills=["alpha-tool"])
|
||||
_write_agent_dir(isolated_paths, agent_id="agent-untouched", skills=["unrelated"])
|
||||
|
||||
counters = await cascade_skill_unpublished_or_deleted(
|
||||
"alpha-tool",
|
||||
actor_user_id="user-a",
|
||||
operation="unpublish",
|
||||
skill_owner_user_id="user-a",
|
||||
agent_store=agent_store,
|
||||
)
|
||||
|
||||
assert counters["modified"] == 2
|
||||
assert counters["notified"] == 1 # only user-b — not the actor
|
||||
|
||||
# A's agent: skill stripped, no notification for the actor.
|
||||
a_cfg = yaml.safe_load((isolated_paths / "agents" / "agent-of-a" / "config.yaml").read_text("utf-8"))
|
||||
assert "alpha-tool" not in a_cfg["skills"]
|
||||
assert "other" in a_cfg["skills"]
|
||||
|
||||
# B's agent: skill stripped, notification delivered to B.
|
||||
b_cfg = yaml.safe_load((isolated_paths / "agents" / "agent-of-b" / "config.yaml").read_text("utf-8"))
|
||||
assert b_cfg["skills"] == []
|
||||
|
||||
user_b_inbox = await notification_store.list_for_user("user-b")
|
||||
assert len(user_b_inbox) == 1
|
||||
note = user_b_inbox[0]
|
||||
assert note["type"] == "agent_skill_removed"
|
||||
assert note["payload"]["skill_name"] == "alpha-tool"
|
||||
assert note["payload"]["agent_id"] == "agent-of-b"
|
||||
assert note["payload"]["operation"] == "unpublish"
|
||||
|
||||
# Untouched agent retains its config + no notification fired for it.
|
||||
untouched_cfg = yaml.safe_load(
|
||||
(isolated_paths / "agents" / "agent-untouched" / "config.yaml").read_text("utf-8")
|
||||
)
|
||||
assert untouched_cfg["skills"] == ["unrelated"]
|
||||
assert await notification_store.unread_count("user-a") == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cascade_admin_takedown_also_notifies_skill_owner(
|
||||
isolated_paths, notification_store
|
||||
) -> None:
|
||||
"""Admin force-takedown emits an extra ``skill_taken_down`` notification to
|
||||
the original skill owner, on top of any per-agent notifications."""
|
||||
agent_store = MemoryAgentStore()
|
||||
await agent_store.create({"id": "agent-of-b", "name": "B", "user_id": "user-b"})
|
||||
_write_agent_dir(isolated_paths, agent_id="agent-of-b", skills=["alpha-tool"])
|
||||
|
||||
await cascade_skill_unpublished_or_deleted(
|
||||
"alpha-tool",
|
||||
actor_user_id="admin-id",
|
||||
operation="admin_takedown",
|
||||
skill_owner_user_id="user-a",
|
||||
agent_store=agent_store,
|
||||
)
|
||||
|
||||
# B (agent owner) gets the agent-skill-removed notification.
|
||||
assert (await notification_store.unread_count("user-b")) == 1
|
||||
# A (skill owner, not the actor) gets the takedown notification.
|
||||
a_inbox = await notification_store.list_for_user("user-a")
|
||||
assert len(a_inbox) == 1
|
||||
assert a_inbox[0]["type"] == "skill_taken_down"
|
||||
assert a_inbox[0]["payload"]["skill_name"] == "alpha-tool"
|
||||
# Admin (the actor) gets nothing.
|
||||
assert (await notification_store.unread_count("admin-id")) == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Notification inbox CRUD
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_notification_store_crud_and_unread_count() -> None:
|
||||
store = MemoryNotificationStore()
|
||||
|
||||
await store.create({"id": "n1", "user_id": "user-a", "type": "x", "title": "t1"})
|
||||
await store.create({"id": "n2", "user_id": "user-a", "type": "x", "title": "t2"})
|
||||
await store.create({"id": "n3", "user_id": "user-b", "type": "x", "title": "t3"})
|
||||
|
||||
assert (await store.unread_count("user-a")) == 2
|
||||
assert (await store.unread_count("user-b")) == 1
|
||||
|
||||
# list_for_user filters by recipient and respects unread_only.
|
||||
a_items = await store.list_for_user("user-a")
|
||||
assert {item["id"] for item in a_items} == {"n1", "n2"}
|
||||
|
||||
# Mark one read → unread count drops; second listing reflects state.
|
||||
assert await store.mark_read("n1", "user-a") is True
|
||||
assert (await store.unread_count("user-a")) == 1
|
||||
unread = await store.list_for_user("user-a", unread_only=True)
|
||||
assert [item["id"] for item in unread] == ["n2"]
|
||||
|
||||
# Cross-user mark_read is rejected.
|
||||
assert await store.mark_read("n3", "user-a") is False
|
||||
assert (await store.unread_count("user-b")) == 1
|
||||
|
||||
# mark_all_read returns the count of newly-flipped rows.
|
||||
flipped = await store.mark_all_read("user-a")
|
||||
assert flipped == 1
|
||||
assert (await store.unread_count("user-a")) == 0
|
||||
|
||||
# Delete behaves the same way — owner-only.
|
||||
assert await store.delete("n3", "user-a") is False
|
||||
assert await store.delete("n3", "user-b") is True
|
||||
assert (await store.list_for_user("user-b")) == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Curator per-user isolation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_curator_apply_transitions_only_touches_owned_skills(
|
||||
isolated_paths, monkeypatch
|
||||
) -> None:
|
||||
"""User A running curator must not mark / archive user B's skills.
|
||||
|
||||
Reproduces a minimal end-of-life scenario: both users own an idle skill,
|
||||
but only A's should transition to STALE.
|
||||
"""
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from deerflow.skills import curator, usage
|
||||
from deerflow.skills.storage import get_or_new_skill_storage, reset_skill_storage
|
||||
|
||||
# The curator's filter helper normally queries the SkillStore via
|
||||
# ``get_session_factory``; here we substitute a static mapping so the test
|
||||
# stays SQL-free. ``apply_automatic_transitions`` is synchronous so we
|
||||
# cannot wait on the async store from inside it without nested-loop pain.
|
||||
owned_by = {
|
||||
"user-a": {"a-skill"},
|
||||
"user-b": {"b-skill"},
|
||||
}
|
||||
monkeypatch.setattr(curator, "_list_user_owned_skill_names", lambda uid: owned_by.get(uid, set()))
|
||||
|
||||
# Point the on-disk skills root at the temp dir so the usage helpers write
|
||||
# to ``{tmp}/skills/.usage.json`` and not into the global singleton state.
|
||||
reset_skill_storage()
|
||||
monkeypatch.setattr(
|
||||
"deerflow.skills.storage._default_skill_storage",
|
||||
get_or_new_skill_storage(skills_path=str(isolated_paths / "skills")),
|
||||
raising=False,
|
||||
)
|
||||
|
||||
# Stamp both skills as agent-created and old enough to mark stale.
|
||||
old_iso = (datetime.now(UTC) - timedelta(days=90)).isoformat()
|
||||
usage._update_entry(
|
||||
"a-skill",
|
||||
{"source": usage.SOURCE_AGENT, "created_at": old_iso, "last_used_at": old_iso, "state": usage.STATE_ACTIVE},
|
||||
)
|
||||
usage._update_entry(
|
||||
"b-skill",
|
||||
{"source": usage.SOURCE_AGENT, "created_at": old_iso, "last_used_at": old_iso, "state": usage.STATE_ACTIVE},
|
||||
)
|
||||
|
||||
# Run curator transitions as user-a.
|
||||
counters = curator.apply_automatic_transitions("user-a", datetime.now(UTC))
|
||||
|
||||
# Only a-skill counted as checked, only it got transitioned.
|
||||
assert counters["checked"] == 1 # owned-filter narrows the entry set
|
||||
assert counters["archived"] == 1 # 90 days idle == archive_after_days
|
||||
|
||||
# The on-disk state reflects the filter.
|
||||
assert usage.get_entry("a-skill")["state"] == usage.STATE_ARCHIVED
|
||||
assert usage.get_entry("b-skill")["state"] == usage.STATE_ACTIVE
|
||||
|
||||
reset_skill_storage()
|
||||
|
||||
|
||||
def test_curator_log_buckets_records_per_user() -> None:
|
||||
"""Curator log records for one user must not appear in another's view —
|
||||
guards the dict-of-deques refactor in curator_log.py."""
|
||||
curator_log.clear_all()
|
||||
|
||||
curator_log.start_new_run(user_id="user-a")
|
||||
curator_log.record("info", "alpha event", user_id="user-a")
|
||||
|
||||
curator_log.start_new_run(user_id="user-b")
|
||||
curator_log.record("info", "beta event", user_id="user-b")
|
||||
|
||||
a_records = curator_log.get_recent(user_id="user-a")
|
||||
b_records = curator_log.get_recent(user_id="user-b")
|
||||
|
||||
assert [r["message"] for r in a_records] == ["run_started", "alpha event"]
|
||||
assert [r["message"] for r in b_records] == ["run_started", "beta event"]
|
||||
|
||||
# An unknown user gets an empty buffer rather than a cross-user view.
|
||||
assert curator_log.get_recent(user_id="user-c") == []
|
||||
|
||||
curator_log.clear_all()
|
||||
Reference in new issue
Block a user