diff --git a/dashboard/src/api/modules/knowledgeBases.ts b/dashboard/src/api/modules/knowledgeBases.ts index 6a66d2d7..d8cfb256 100644 --- a/dashboard/src/api/modules/knowledgeBases.ts +++ b/dashboard/src/api/modules/knowledgeBases.ts @@ -37,6 +37,7 @@ export interface KnowledgeBase { embedding_model: string; embedding_dim: number; doc_count: number; + max_documents: number; created_at: number; updated_at: number; } @@ -151,6 +152,7 @@ export const knowledgeBasesApi = { default_open?: boolean; shared?: boolean; icon_name?: string; + max_documents?: number; }) => request("/knowledge-bases", { method: "POST", @@ -165,6 +167,7 @@ export const knowledgeBasesApi = { default_open?: boolean; shared?: boolean; icon_name?: string; + max_documents?: number; }, ) => request(`/knowledge-bases/${id}`, { diff --git a/dashboard/src/locales/en.json b/dashboard/src/locales/en.json index bce08fe8..e370bd35 100644 --- a/dashboard/src/locales/en.json +++ b/dashboard/src/locales/en.json @@ -322,6 +322,8 @@ "documents": "Documents", "documentLimit": "{{count}} / {{max}} documents", "documentLimitReached": "This knowledge base already contains the maximum of {{count}} documents.", + "maxDocuments": "Document limit", + "maxDocumentsHint": "Maximum number of files in this knowledge base. 0 = unlimited, default 100.", "documentTooLarge": "Each document must be at most {{sizeMb}} MB.", "baseLimitReached": "You can create at most {{count}} knowledge bases.", "uploadHint": "Supports md / txt / pdf / docx / pptx. Max {{sizeMb}} MB per file.", diff --git a/dashboard/src/locales/zh.json b/dashboard/src/locales/zh.json index d94c3228..59d88adf 100644 --- a/dashboard/src/locales/zh.json +++ b/dashboard/src/locales/zh.json @@ -322,6 +322,8 @@ "documents": "文档", "documentLimit": "{{count}} / {{max}} 个文档", "documentLimitReached": "此知识库已达到 {{count}} 个文档的上限。", + "maxDocuments": "文档数量上限", + "maxDocumentsHint": "本知识库可容纳的最大文档数,0 表示不限制,默认为 100。", "documentTooLarge": "单个文档不能超过 {{sizeMb}} MB。", "baseLimitReached": "每个用户最多可创建 {{count}} 个知识库。", "uploadHint": "支持 md / txt / pdf / docx / pptx,单文件不超过 {{sizeMb}} MB。", diff --git a/dashboard/src/pages/KnowledgeBases/index.tsx b/dashboard/src/pages/KnowledgeBases/index.tsx index 714cb295..43b474d3 100644 --- a/dashboard/src/pages/KnowledgeBases/index.tsx +++ b/dashboard/src/pages/KnowledgeBases/index.tsx @@ -12,6 +12,7 @@ import { Empty, Form, Input, + InputNumber, List, Modal, Popconfirm, @@ -94,6 +95,7 @@ type BaseFormValues = { name: string; description?: string; icon_name?: string; + max_documents?: number; }; type DocsViewMode = "card" | "table"; @@ -327,7 +329,8 @@ export default function KnowledgeBasesPage() { : 0; const atBaseLimit = ownedBaseCount >= limits.max_bases_per_owner; const fileCount = documents.filter((document) => !document.is_dir).length; - const isAtDocumentLimit = fileCount >= limits.max_docs_per_kb; + const isAtDocumentLimit = + fileCount >= (selected?.max_documents ?? limits.max_docs_per_kb); const folderEntries = documents .filter((document) => isDirectKnowledgeChild(document.path || document.filename, currentFolder), @@ -598,6 +601,7 @@ export default function KnowledgeBasesPage() { name: "", description: "", icon_name: "book-open", + max_documents: 100, }); setDefaultOpenChecked(false); setSharedChecked(false); @@ -611,6 +615,7 @@ export default function KnowledgeBasesPage() { name: selected.name, description: selected.description, icon_name: selected.icon_name || undefined, + max_documents: selected.max_documents, }); setDefaultOpenChecked(selected.default_open); setSharedChecked(selected.shared); @@ -813,7 +818,10 @@ export default function KnowledgeBasesPage() { const uploadDocuments = async (files: FileList | null) => { if (!selected || !files || !usable || isAtDocumentLimit) return; - const remaining = Math.max(0, limits.max_docs_per_kb - fileCount); + const remaining = Math.max( + 0, + (selected?.max_documents ?? limits.max_docs_per_kb) - fileCount, + ); const chosen = Array.from(files).slice(0, remaining); const oversized = chosen.filter( (file) => file.size > limits.max_document_bytes, @@ -1476,7 +1484,7 @@ export default function KnowledgeBasesPage() { {t("knowledgeBases.documentLimit", { count: fileCount, - max: limits.max_docs_per_kb, + max: selected?.max_documents ?? limits.max_docs_per_kb, })}
@@ -1607,7 +1615,7 @@ export default function KnowledgeBasesPage() { type="info" showIcon message={t("knowledgeBases.documentLimitReached", { - count: limits.max_docs_per_kb, + count: selected?.max_documents ?? limits.max_docs_per_kb, })} /> ) : null} @@ -1937,6 +1945,19 @@ export default function KnowledgeBasesPage() { showCount /> + + + diff --git a/src/octop/api/routers/knowledge_bases.py b/src/octop/api/routers/knowledge_bases.py index 8e8bf167..dc24ec00 100644 --- a/src/octop/api/routers/knowledge_bases.py +++ b/src/octop/api/routers/knowledge_bases.py @@ -69,6 +69,12 @@ class CreateBaseBody(BaseModel): default_open: bool = False shared: bool = False icon_name: str = Field(default="", max_length=64) + max_documents: int | None = Field( + default=None, + ge=0, + le=10_000, + description="Per-base document limit. 0 = unlimited, default 100.", + ) class CreateFolderBody(BaseModel): @@ -98,6 +104,12 @@ class UpdateBaseBody(BaseModel): default_open: bool | None = None shared: bool | None = None icon_name: str | None = Field(default=None, max_length=64) + max_documents: int | None = Field( + default=None, + ge=0, + le=10_000, + description="Per-base document limit. 0 = unlimited.", + ) class RenameDocumentBody(BaseModel): @@ -418,6 +430,7 @@ async def create_base( default_open=body.default_open, shared=body.shared, icon_name=body.icon_name.strip(), + max_documents=body.max_documents if body.max_documents is not None else MAX_DOCS_PER_KB, ) return _base_payload(server, base) except Exception as exc: @@ -480,6 +493,7 @@ async def update_base( default_open=body.default_open, shared=body.shared, icon_name=body.icon_name.strip() if body.icon_name is not None else None, + max_documents=body.max_documents, is_admin=_is_admin(user), ), ) diff --git a/src/octop/infra/db/migrate.py b/src/octop/infra/db/migrate.py index 9928b828..119f3edd 100644 --- a/src/octop/infra/db/migrate.py +++ b/src/octop/infra/db/migrate.py @@ -637,6 +637,8 @@ def _ensure_knowledge_bases_schema(db: DatabasePool) -> None: """Create or rebuild knowledge tables to the integer-PK identity schema.""" _rebuild_knowledge_identity_schema(db) _drop_knowledge_base_members(db) + # Schema v10: per-knowledge-base configurable document limit. + _ensure_column(db, "knowledge_bases", "max_documents", "INTEGER NOT NULL DEFAULT 100") def _ensure_sso_oidc_schema(db: DatabasePool) -> None: diff --git a/src/octop/infra/db/migrations/010_kb_max_documents.pg.sql b/src/octop/infra/db/migrations/010_kb_max_documents.pg.sql new file mode 100644 index 00000000..c0a0598f --- /dev/null +++ b/src/octop/infra/db/migrations/010_kb_max_documents.pg.sql @@ -0,0 +1,7 @@ +-- Schema v10: per-knowledge-base configurable document limit. +-- PostgreSQL: ADD COLUMN IF NOT EXISTS is safe (skips if already present). + +ALTER TABLE knowledge_bases + ADD COLUMN IF NOT EXISTS max_documents INTEGER NOT NULL DEFAULT 100; + +UPDATE _schema_version SET version = 10; diff --git a/src/octop/infra/db/migrations/010_kb_max_documents.sql b/src/octop/infra/db/migrations/010_kb_max_documents.sql new file mode 100644 index 00000000..523e4bed --- /dev/null +++ b/src/octop/infra/db/migrations/010_kb_max_documents.sql @@ -0,0 +1,6 @@ +-- Schema v10: per-knowledge-base configurable document limit. +-- The column is added by migrate.py::_ensure_knowledge_bases_schema so that +-- boot-time repair covers pre-v10 databases. This file only bumps _schema_version. +-- 100 is the previous system-wide default; 0 means unlimited. + +UPDATE _schema_version SET version = 10; diff --git a/src/octop/infra/db/repos/knowledge.py b/src/octop/infra/db/repos/knowledge.py index 2455c4cf..c5b28fa5 100644 --- a/src/octop/infra/db/repos/knowledge.py +++ b/src/octop/infra/db/repos/knowledge.py @@ -31,11 +31,16 @@ class KnowledgeBaseRow: embedding_model: str embedding_dim: int doc_count: int + max_documents: int created_at: int updated_at: int @classmethod def from_row(cls, r: DbRow) -> KnowledgeBaseRow: + # Schema v10 adds max_documents. Fall back to 100 for pre-v10 DBs. + # sqlite3.Row has no __contains__; use keys() (like users.py). + keys = frozenset(r.keys()) if hasattr(r, "keys") else frozenset() + max_doc = int(r["max_documents"]) if "max_documents" in keys else 100 return cls( id=str(r["knowledge_base_id"]), pk=int(r["id"]), @@ -48,6 +53,7 @@ def from_row(cls, r: DbRow) -> KnowledgeBaseRow: embedding_model=r["embedding_model"], embedding_dim=r["embedding_dim"], doc_count=r["doc_count"], + max_documents=max_doc, created_at=r["created_at"], updated_at=r["updated_at"], ) @@ -112,29 +118,54 @@ def create_base( icon_name: str = "", embedding_model: str = "", embedding_dim: int = 0, + max_documents: int | None = None, ) -> KnowledgeBaseRow: kb_id = self._allocate_base_id() ts = now_ts() with self._db.transaction() as conn: - conn.execute( - "INSERT INTO knowledge_bases(" - "knowledge_base_id, owner_user_id, name, description, default_open, shared, " - "icon_name, embedding_model, embedding_dim, doc_count, created_at, updated_at" - ") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?)", - ( - kb_id, - owner_user_id, - name, - description, - bool_int(default_open), - bool_int(shared), - icon_name, - embedding_model, - embedding_dim, - ts, - ts, - ), - ) + if max_documents is None: + # Rely on the column DEFAULT (100) for max_documents. + conn.execute( + "INSERT INTO knowledge_bases(" + "knowledge_base_id, owner_user_id, name, description, default_open, shared, " + "icon_name, embedding_model, embedding_dim, doc_count, created_at, updated_at" + ") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?)", + ( + kb_id, + owner_user_id, + name, + description, + bool_int(default_open), + bool_int(shared), + icon_name, + embedding_model, + embedding_dim, + ts, + ts, + ), + ) + else: + conn.execute( + "INSERT INTO knowledge_bases(" + "knowledge_base_id, owner_user_id, name, description, default_open, shared, " + "icon_name, embedding_model, embedding_dim, doc_count, max_documents, " + "created_at, updated_at" + ") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?)", + ( + kb_id, + owner_user_id, + name, + description, + bool_int(default_open), + bool_int(shared), + icon_name, + embedding_model, + embedding_dim, + max_documents, + ts, + ts, + ), + ) row = self.get_base(kb_id) if row is None: raise RuntimeError(f"knowledge base insert failed: {kb_id}") @@ -183,6 +214,7 @@ def update_base( embedding_model: str | None = None, embedding_dim: int | None = None, doc_count: int | None = None, + max_documents: int | None = None, ) -> None: fields, params = partial_updates( [ @@ -194,6 +226,7 @@ def update_base( ("embedding_model", embedding_model), ("embedding_dim", embedding_dim), ("doc_count", doc_count), + ("max_documents", max_documents), ] ) if not fields: @@ -264,8 +297,12 @@ def create_document( self.ensure_folder(kb_id, folder) doc_id = new_ulid() ts = now_ts() + # Treat both None (caller did not specify) and 0 (per-base "unlimited" + # sentinel) as unbounded. Otherwise 0 would be enforced literally as + # "at most 0 documents" and reject every create. + enforce_limit = max_documents is not None and max_documents > 0 with self._db.transaction() as conn: - if max_documents is not None: + if enforce_limit: cursor = conn.execute( "UPDATE knowledge_bases SET doc_count = doc_count + 1, updated_at = ? " "WHERE knowledge_base_id = ? AND doc_count < ?", @@ -280,7 +317,7 @@ def create_document( ") VALUES (?, ?, ?, ?, 0, ?, ?, ?, ?, '', 0, ?, ?)", (doc_id, kb_id, rel, name, content_type, byte_size, content_hash, status, ts, ts), ) - if max_documents is None: + if not enforce_limit: conn.execute( "UPDATE knowledge_bases SET doc_count = doc_count + 1, updated_at = ? " "WHERE knowledge_base_id = ?", diff --git a/src/octop/infra/knowledge/service.py b/src/octop/infra/knowledge/service.py index a6a5ab5e..d6e9d56e 100644 --- a/src/octop/infra/knowledge/service.py +++ b/src/octop/infra/knowledge/service.py @@ -24,6 +24,8 @@ MAX_DOCS_PER_KB = 100 MAX_BASES_PER_OWNER = 20 MAX_DOCUMENT_BYTES = upload_mb_to_bytes(DEFAULT_MAX_UPLOAD_MB) +# Upper bound for the per-base max_documents field. Mirrors Field(le=10000). +MAX_KB_MAX_DOCUMENTS = 10_000 _MAX_PREVIEW_CHARS = 200_000 _EXT_TO_CONTENT_TYPE = { ".txt": "text/plain", @@ -73,10 +75,13 @@ def create_base( default_open: bool = False, shared: bool = False, icon_name: str = "", + max_documents: int = MAX_DOCS_PER_KB, ) -> KnowledgeBaseRow: assert_knowledge_usable( self._services.settings_repo.get, getattr(self._services, "provider_repo", None) ) + if max_documents < 0 or max_documents > MAX_KB_MAX_DOCUMENTS: + raise ValueError(f"max_documents must be between 0 and {MAX_KB_MAX_DOCUMENTS}") owned = self._repo.count_bases_for_owner(owner_user_id) if owned >= MAX_BASES_PER_OWNER: raise ValueError(f"a user can own at most {MAX_BASES_PER_OWNER} knowledge bases") @@ -91,6 +96,7 @@ def create_base( shared=shared, icon_name=icon_name, embedding_model=model, + max_documents=max_documents, ), ) @@ -111,9 +117,14 @@ def update_base( default_open: bool | None = None, shared: bool | None = None, icon_name: str | None = None, + max_documents: int | None = None, is_admin: bool = False, ) -> KnowledgeBaseRow: self.require_owner(kb_id, actor_user_id=actor_user_id, is_admin=is_admin) + if max_documents is not None and ( + max_documents < 0 or max_documents > MAX_KB_MAX_DOCUMENTS + ): + raise ValueError(f"max_documents must be between 0 and {MAX_KB_MAX_DOCUMENTS}") self._repo.update_base( kb_id, name=name, @@ -121,6 +132,7 @@ def update_base( default_open=default_open, shared=shared, icon_name=icon_name, + max_documents=max_documents, ) return self._require_base(kb_id) @@ -294,7 +306,7 @@ def upload_document( assert_knowledge_usable( self._services.settings_repo.get, getattr(self._services, "provider_repo", None) ) - self.get_writable_base(kb_id, actor_user_id=actor_user_id, is_admin=is_admin) + base = self.get_writable_base(kb_id, actor_user_id=actor_user_id, is_admin=is_admin) limit = self._max_document_bytes() if len(content) > limit: raise ValueError(f"knowledge document size exceeds maximum of {limit} bytes") @@ -305,13 +317,14 @@ def upload_document( resolved_type = _resolve_content_type(name, content_type) if resolved_type not in _ALLOWED_CONTENT_TYPES: raise ValueError(f"unsupported knowledge document content type: {content_type}") + # The per-base limit lives on the KB row (schema v10). 0 = unlimited. document = self._repo.create_document( kb_id=kb_id, filename=name, path=rel, content_type=resolved_type, byte_size=len(content), - max_documents=MAX_DOCS_PER_KB, + max_documents=base.max_documents, ) try: write_document(kb_id, document.id, name, content) diff --git a/tests/unit/api/test_knowledge_bases.py b/tests/unit/api/test_knowledge_bases.py index 839e4a5f..d0a50968 100644 --- a/tests/unit/api/test_knowledge_bases.py +++ b/tests/unit/api/test_knowledge_bases.py @@ -39,6 +39,7 @@ class _Base: embedding_model: str = "model" embedding_dim: int = 0 doc_count: int = 0 + max_documents: int = 100 created_at: int = 1 updated_at: int = 1 @@ -199,6 +200,7 @@ async def test_create_base_uses_selected_model_when_usable( "default_open": False, "shared": False, "icon_name": "", + "max_documents": 100, } @@ -550,3 +552,42 @@ def fail(*_args: object, **_kwargs: object) -> None: ) assert raised.value.code == ErrorCode.KNOWLEDGE_NAME_INVALID + + +@pytest.mark.asyncio +async def test_update_base_accepts_max_documents(monkeypatch: pytest.MonkeyPatch) -> None: + from octop.api.routers import knowledge_bases + + received: dict[str, object] = {} + + class _StubSvc: + def update_base(self, *_: object, **kw: object) -> object: + received.update(kw) + return _Base(max_documents=42) + + monkeypatch.setattr(knowledge_bases, "_knowledge_service", lambda _s: _StubSvc()) + + response = await knowledge_bases.update_base( + kb_id="kb-1", + body=knowledge_bases.UpdateBaseBody(max_documents=42), + request=_request(), + server=SimpleNamespace(services=_services()), + user=SimpleNamespace(id=1, is_admin=False), + ) + assert received["max_documents"] == 42 + assert response["max_documents"] == 42 + + +@pytest.mark.asyncio +async def test_update_base_rejects_max_documents_out_of_range() -> None: + from pydantic import ValidationError + + from octop.api.routers import knowledge_bases + + with pytest.raises(ValidationError): + knowledge_bases.UpdateBaseBody(max_documents=-1) + with pytest.raises(ValidationError): + knowledge_bases.UpdateBaseBody(max_documents=10_001) + assert knowledge_bases.UpdateBaseBody(max_documents=0).max_documents == 0 + assert knowledge_bases.UpdateBaseBody(max_documents=10_000).max_documents == 10_000 + assert knowledge_bases.UpdateBaseBody().max_documents is None diff --git a/tests/unit/db/test_agent_profile_columns.py b/tests/unit/db/test_agent_profile_columns.py index be5f95cd..1b373b61 100644 --- a/tests/unit/db/test_agent_profile_columns.py +++ b/tests/unit/db/test_agent_profile_columns.py @@ -110,7 +110,7 @@ def test_migration_007_backfills_profile_columns(tmp_path: Path) -> None: with pool.connect() as conn: version = conn.execute("SELECT version FROM _schema_version").fetchone()[0] row = conn.execute("SELECT * FROM agents WHERE agent_id = ?", ("ag1",)).fetchone() - assert version == 9 + assert version == 10 assert row["template_name"] == "general-assistant" assert row["icon_name"] == "zap" assert row["icon_url"] == "https://cdn.example.com/a.png" diff --git a/tests/unit/db/test_clip_thread_title.py b/tests/unit/db/test_clip_thread_title.py index 8afe0c67..d916cb1b 100644 --- a/tests/unit/db/test_clip_thread_title.py +++ b/tests/unit/db/test_clip_thread_title.py @@ -82,7 +82,7 @@ def test_migration_003_repairs_stored_hard_cuts(tmp_path: Path) -> None: with pool.connect() as conn: v = conn.execute("SELECT version FROM _schema_version").fetchone()[0] title = conn.execute("SELECT title FROM threads WHERE thread_id = ?", ("t1",)).fetchone()[0] - assert v == 9 + assert v == 10 assert title == "x" * 39 + "…" # Idempotent repair assert repair_all_legacy_thread_titles(pool) == 0 diff --git a/tests/unit/db/test_db_pool.py b/tests/unit/db/test_db_pool.py index 7d7a1789..968df763 100644 --- a/tests/unit/db/test_db_pool.py +++ b/tests/unit/db/test_db_pool.py @@ -85,7 +85,7 @@ def test_run_migrations_idempotent(db: SqlitePool): doc_cols = { r["name"] for r in conn.execute("PRAGMA table_info(knowledge_documents)").fetchall() } - assert v == 9 + assert v == 10 assert "login_failed_count" in cols assert "login_locked_until" in cols assert "preferences_json" in cols @@ -143,7 +143,7 @@ def test_migration_002_idempotent_when_column_already_present(tmp_path: Path) -> with pool.connect() as conn: v = conn.execute("SELECT version FROM _schema_version").fetchone()[0] cron_cols = {r["name"] for r in conn.execute("PRAGMA table_info(cron_jobs)").fetchall()} - assert v == 9 + assert v == 10 assert "mcp_servers" in cron_cols assert "skill_packages" in { r["name"] @@ -280,7 +280,7 @@ def test_stuck_version_6_without_permissions_column_is_repaired(tmp_path: Path) with pool.connect() as conn: cols = {r["name"] for r in conn.execute("PRAGMA table_info(users)").fetchall()} version = conn.execute("SELECT version FROM _schema_version").fetchone()[0] - assert version == 9 + assert version == 10 assert "permissions" in cols @@ -311,7 +311,7 @@ def test_ahead_of_max_schema_version_clamps_to_max(tmp_path: Path) -> None: r["name"] for r in conn.execute("SELECT name FROM sqlite_master WHERE type='table'").fetchall() } - assert version == 9 + assert version == 10 assert "skill_package_id" in pkg_cols assert "published_expert_id" in pub_cols assert "user_invites" in invite_tables @@ -350,7 +350,7 @@ def test_pre_squash_schema_version_clamped_and_knowledge_tables_filled( for row in conn.execute("SELECT name FROM sqlite_master WHERE type='table'").fetchall() } user_cols = {r["name"] for r in conn.execute("PRAGMA table_info(users)").fetchall()} - assert version == 9 + assert version == 10 assert "permissions" in user_cols assert { "published_experts", diff --git a/tests/unit/db/test_published_experts_repo.py b/tests/unit/db/test_published_experts_repo.py index 5ac92957..4705d7f2 100644 --- a/tests/unit/db/test_published_experts_repo.py +++ b/tests/unit/db/test_published_experts_repo.py @@ -27,7 +27,7 @@ def test_published_experts_table_exists(db: SqlitePool) -> None: v = conn.execute("SELECT version FROM _schema_version").fetchone()[0] cols = {r["name"] for r in conn.execute("PRAGMA table_info(published_experts)").fetchall()} assert "published_experts" in names - assert v == 9 + assert v == 10 assert "published_expert_id" in cols diff --git a/tests/unit/db/test_repo_knowledge.py b/tests/unit/db/test_repo_knowledge.py index 7b81ee73..00d38a4f 100644 --- a/tests/unit/db/test_repo_knowledge.py +++ b/tests/unit/db/test_repo_knowledge.py @@ -50,11 +50,11 @@ def test_knowledge_tables_migrated(db: SqlitePool) -> None: "knowledge_bases", "knowledge_documents", }.issubset(names) - assert v == 9 + assert v == 10 assert "knowledge_base_members" not in names - assert "knowledge_base_id" in { - r["name"] for r in conn.execute("PRAGMA table_info(knowledge_bases)").fetchall() - } + cols = {r["name"] for r in conn.execute("PRAGMA table_info(knowledge_bases)").fetchall()} + assert "knowledge_base_id" in cols + assert "max_documents" in cols def test_path_layout_knowledge_dir(tmp_path: Path) -> None: @@ -296,3 +296,53 @@ def test_rename_document_missing_or_wrong_kb_returns_none( assert repo.rename_document("missing-kb", folder.id, "x") is None assert repo.rename_document(other.id, folder.id, "x") is None assert repo.rename_document(kb.id, "missing-doc", "x") is None + + +def test_update_base_persists_max_documents(repo: KnowledgeRepo, owner_id: int) -> None: + kb = repo.create_base(owner_user_id=owner_id, name="Docs") + assert kb.max_documents == 100 + repo.update_base(kb.id, max_documents=42) + refreshed = repo.get_base(kb.id) + assert refreshed is not None + assert refreshed.max_documents == 42 + repo.update_base(kb.id, description="unchanged") + assert repo.get_base(kb.id).max_documents == 42 # type: ignore[union-attr] + + +def test_create_document_uses_kb_max_documents_for_limit( + repo: KnowledgeRepo, owner_id: int +) -> None: + # The repo enforces whatever max_documents the caller passes (the service + # layer forwards kb.max_documents). A cap of 2 must reject the 3rd. + kb = repo.create_base(owner_user_id=owner_id, name="Tiny", max_documents=2) + assert kb.max_documents == 2 + for i in range(2): + repo.create_document( + kb_id=kb.id, + filename=f"d{i}.md", + content_type="text/markdown", + byte_size=2, + max_documents=kb.max_documents, + ) + with pytest.raises(ValueError, match="at most 2 documents"): + repo.create_document( + kb_id=kb.id, + filename="d2.md", + content_type="text/markdown", + byte_size=2, + max_documents=kb.max_documents, + ) + + +def test_create_document_zero_max_means_unlimited(repo: KnowledgeRepo, owner_id: int) -> None: + kb = repo.create_base(owner_user_id=owner_id, name="Unbounded", max_documents=0) + assert kb.max_documents == 0 + for i in range(150): + repo.create_document( + kb_id=kb.id, + filename=f"d{i:03}.md", + content_type="text/markdown", + byte_size=2, + max_documents=kb.max_documents, + ) + assert repo.get_base(kb.id).doc_count == 150 # type: ignore[union-attr] diff --git a/tests/unit/db/test_skill_package_icons.py b/tests/unit/db/test_skill_package_icons.py index 9eb8d1fe..27ac3646 100644 --- a/tests/unit/db/test_skill_package_icons.py +++ b/tests/unit/db/test_skill_package_icons.py @@ -91,7 +91,7 @@ def test_migration_002_idempotent_when_icon_columns_already_present(tmp_path: Pa "SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='skill_packages'" ) } - assert v == 9 + assert v == 10 assert "icon_name" in cols assert "icon_url" in cols assert "skill_package_id" in cols @@ -110,7 +110,7 @@ def test_repair_legacy_schema_adds_icon_columns_at_version_2(tmp_path: Path) -> with pool.connect() as conn: v = conn.execute("SELECT version FROM _schema_version").fetchone()[0] cols = {r["name"] for r in conn.execute("PRAGMA table_info(skill_packages)").fetchall()} - assert v == 9 + assert v == 10 assert "icon_name" in cols assert "icon_url" in cols assert "skill_package_id" in cols diff --git a/tests/unit/db/test_skill_packages_repo.py b/tests/unit/db/test_skill_packages_repo.py index 04259d7d..67d7cc58 100644 --- a/tests/unit/db/test_skill_packages_repo.py +++ b/tests/unit/db/test_skill_packages_repo.py @@ -28,7 +28,7 @@ def test_skill_packages_table_exists(db: SqlitePool) -> None: v = conn.execute("SELECT version FROM _schema_version").fetchone()[0] cols = {r["name"] for r in conn.execute("PRAGMA table_info(skill_packages)").fetchall()} assert "skill_packages" in names - assert v == 9 + assert v == 10 assert "skill_package_id" in cols diff --git a/tests/unit/knowledge/test_service_acl.py b/tests/unit/knowledge/test_service_acl.py index 35c1676d..7c6bdcc0 100644 --- a/tests/unit/knowledge/test_service_acl.py +++ b/tests/unit/knowledge/test_service_acl.py @@ -256,3 +256,24 @@ def test_shared_reader_cannot_rename(service: KnowledgeService) -> None: with pytest.raises(PermissionError, match="write"): service.rename_document(kb.id, folder.id, actor_user_id=viewer, new_name="b") + + +def test_update_base_validates_max_documents_range(service: KnowledgeService) -> None: + users = service._services.user_repo + owner = users.create(username="ow", password_hash="h", role="user") + kb = service.create_base(owner_user_id=owner, name="Docs") + # In range + service.update_base(kb.id, actor_user_id=owner, max_documents=0) + assert service.get_readable_base(kb.id, actor_user_id=owner).max_documents == 0 + service.update_base(kb.id, actor_user_id=owner, max_documents=500) + assert service.get_readable_base(kb.id, actor_user_id=owner).max_documents == 500 + service.update_base(kb.id, actor_user_id=owner, max_documents=10_000) + assert service.get_readable_base(kb.id, actor_user_id=owner).max_documents == 10_000 + # Out of range + with pytest.raises(ValueError, match="max_documents must be between"): + service.update_base(kb.id, actor_user_id=owner, max_documents=-1) + with pytest.raises(ValueError, match="max_documents must be between"): + service.update_base(kb.id, actor_user_id=owner, max_documents=10_001) + # Omit keeps value + service.update_base(kb.id, actor_user_id=owner, description="keep") + assert service.get_readable_base(kb.id, actor_user_id=owner).max_documents == 10_000