Files
arc53--docsgpt/tests/worker/test_convert_source_to_wiki.py
wehub-resource-sync fed8b2eed7
Build and push multi-arch DocsGPT Docker image / build (linux/amd64, ubuntu-latest, amd64) (push) Has been cancelled
Backend release / release (push) Has been cancelled
Bandit Security Scan / bandit_scan (push) Has been cancelled
Build and push multi-arch DocsGPT Docker image / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Has been cancelled
Build and push multi-arch DocsGPT Docker image / manifest (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / build (linux/amd64, ubuntu-latest, amd64) (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / manifest (push) Has been cancelled
Python linting / ruff (push) Has been cancelled
Run python tests with pytest / Run tests and count coverage (3.12) (push) Has been cancelled
React Widget Build / build (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:28:29 +08:00

441 lines
16 KiB
Python

"""Tests for ``application.worker.convert_source_to_wiki_worker``.
The worker reassembles wiki pages from a source's existing vector-store
chunks (grouped by ``metadata.source``) and enqueues a per-page re-embed.
``VectorCreator.create_vectorstore`` and the re-embed task are mocked so no
real store access or embeddings run; the ``sources`` / ``wiki_pages`` rows
are real so the assertions read them back.
"""
from __future__ import annotations
from unittest.mock import MagicMock
import pytest
from application.storage.db.repositories.sources import SourcesRepository
from application.storage.db.repositories.wiki_pages import WikiPagesRepository
def _seed_source(pg_conn, config=None, file_path=""):
src = SourcesRepository(pg_conn).create(
"doc-set",
user_id="alice",
type="crawler",
retriever="classic",
file_path=file_path,
config=config or {},
directory_structure={"a.md": {"type": "text/markdown", "size_bytes": 1}},
)
return str(src["id"])
def _chunk(text, source=None, doc_id="d", **extra_meta):
metadata = dict(extra_meta)
if source is not None:
metadata["source"] = source
return {"doc_id": doc_id, "text": text, "metadata": metadata}
def _patch_store(monkeypatch, chunks):
store = MagicMock(name="vectorstore")
store.get_chunks.return_value = chunks
monkeypatch.setattr(
"application.vectorstore.vector_creator.VectorCreator.create_vectorstore",
lambda *a, **kw: store,
)
return store
def _patch_reembed(monkeypatch):
delay = MagicMock(name="reembed_delay")
monkeypatch.setattr("application.api.user.tasks.reembed_wiki_page.delay", delay)
return delay
@pytest.mark.unit
class TestConvertSourceToWikiWorker:
def test_two_pages_reassembled_from_chunks(
self, pg_conn, patch_worker_db, task_self, monkeypatch
):
from application import worker
source_id = _seed_source(pg_conn)
_patch_store(
monkeypatch,
[
_chunk("guide part one.", source="guide.md", doc_id="g1"),
_chunk("guide part two.", source="guide.md", doc_id="g2"),
_chunk("intro body.", source="notes/intro.md", doc_id="i1"),
],
)
delay = _patch_reembed(monkeypatch)
result = worker.convert_source_to_wiki_worker(task_self, source_id, "alice")
assert result["status"] == "converted"
assert result["pages_created"] == 2
pages = WikiPagesRepository(pg_conn).list_for_source(source_id)
by_path = {p["path"]: p for p in pages}
assert set(by_path) == {"/guide.md", "/notes/intro.md"}
assert by_path["/guide.md"]["content"] == "guide part one.\n\nguide part two."
assert by_path["/guide.md"]["title"] == "guide.md"
assert by_path["/notes/intro.md"]["content"] == "intro body."
assert by_path["/notes/intro.md"]["title"] == "intro.md"
assert delay.call_count == 2
paths = {c.args[1] for c in delay.call_args_list}
assert paths == {"/guide.md", "/notes/intro.md"}
for call in delay.call_args_list:
assert call.args[0] == source_id
assert call.kwargs["user"] == "alice"
assert call.kwargs["idempotency_key"].startswith(
f"reembed-wiki:{source_id}:"
)
def test_original_chunks_deleted_after_convert(
self, pg_conn, patch_worker_db, task_self, monkeypatch
):
from application import worker
source_id = _seed_source(pg_conn)
store = _patch_store(
monkeypatch,
[
_chunk("guide part one.", source="guide.md", doc_id="g1"),
_chunk("guide part two.", source="guide.md", doc_id="g2"),
_chunk("intro body.", source="notes/intro.md", doc_id="i1"),
],
)
delay = _patch_reembed(monkeypatch)
result = worker.convert_source_to_wiki_worker(task_self, source_id, "alice")
assert result["status"] == "converted"
deleted = {c.args[0] for c in store.delete_chunk.call_args_list}
assert deleted == {"g1", "g2", "i1"}
assert delay.call_count == 2
def test_chunk_without_doc_id_not_deleted(
self, pg_conn, patch_worker_db, task_self, monkeypatch
):
from application import worker
source_id = _seed_source(pg_conn)
chunk = {"text": "body", "metadata": {"source": "a.md"}}
store = _patch_store(monkeypatch, [chunk])
_patch_reembed(monkeypatch)
result = worker.convert_source_to_wiki_worker(task_self, source_id, "alice")
assert result["status"] == "converted"
store.delete_chunk.assert_not_called()
def test_skipped_chunk_with_doc_id_still_deleted(
self, pg_conn, patch_worker_db, task_self, monkeypatch
):
# A chunk skipped for an invalid path must still have its original
# vector chunk purged, not left orphaned after the source flips to wiki.
from application import worker
source_id = _seed_source(pg_conn)
store = _patch_store(
monkeypatch,
[
_chunk("good", source="ok.md", doc_id="ok"),
_chunk("bad", source="../evil.md", doc_id="evil"),
],
)
_patch_reembed(monkeypatch)
result = worker.convert_source_to_wiki_worker(task_self, source_id, "alice")
assert result["pages_created"] == 1
deleted = {c.args[0] for c in store.delete_chunk.call_args_list}
assert deleted == {"ok", "evil"}
def test_no_pages_path_deletes_nothing(
self, pg_conn, patch_worker_db, task_self, monkeypatch
):
from application import worker
source_id = _seed_source(pg_conn)
store = _patch_store(monkeypatch, [])
_patch_reembed(monkeypatch)
result = worker.convert_source_to_wiki_worker(task_self, source_id, "alice")
assert result["status"] == "no_pages"
store.delete_chunk.assert_not_called()
def test_kind_flipped_and_exposure_defaulted(
self, pg_conn, patch_worker_db, task_self, monkeypatch
):
from application import worker
from application.storage.db.source_config import SourceConfig
source_id = _seed_source(pg_conn)
_patch_store(monkeypatch, [_chunk("body", source="a.md")])
_patch_reembed(monkeypatch)
worker.convert_source_to_wiki_worker(task_self, source_id, "alice")
refreshed = SourcesRepository(pg_conn).get_any(source_id, "alice")
cfg = SourceConfig.parse(refreshed.get("config"))
assert cfg.kind == "wiki"
assert cfg.retrieval.exposure == "agentic_tool"
def test_preserves_non_default_exposure(
self, pg_conn, patch_worker_db, task_self, monkeypatch
):
from application import worker
from application.storage.db.source_config import SourceConfig
source_id = _seed_source(
pg_conn, config={"retrieval": {"exposure": "agentic_tool"}}
)
_patch_store(monkeypatch, [_chunk("body", source="a.md")])
_patch_reembed(monkeypatch)
worker.convert_source_to_wiki_worker(task_self, source_id, "alice")
refreshed = SourcesRepository(pg_conn).get_any(source_id, "alice")
cfg = SourceConfig.parse(refreshed.get("config"))
assert cfg.kind == "wiki"
assert cfg.retrieval.exposure == "agentic_tool"
def test_chunk_order_hint_respected(
self, pg_conn, patch_worker_db, task_self, monkeypatch
):
from application import worker
source_id = _seed_source(pg_conn)
_patch_store(
monkeypatch,
[
_chunk("second", source="a.md", doc_id="a2", chunk_index=1),
_chunk("first", source="a.md", doc_id="a1", chunk_index=0),
],
)
_patch_reembed(monkeypatch)
worker.convert_source_to_wiki_worker(task_self, source_id, "alice")
pages = WikiPagesRepository(pg_conn).list_for_source(source_id)
by_path = {p["path"]: p for p in pages}
assert by_path["/a.md"]["content"] == "first\n\nsecond"
def test_short_overlap_not_trimmed(
self, pg_conn, patch_worker_db, task_self, monkeypatch
):
from application import worker
source_id = _seed_source(pg_conn)
_patch_store(
monkeypatch,
[
_chunk("The end.", source="a.md", doc_id="a1"),
_chunk("The end. More", source="a.md", doc_id="a2"),
],
)
_patch_reembed(monkeypatch)
worker.convert_source_to_wiki_worker(task_self, source_id, "alice")
pages = WikiPagesRepository(pg_conn).list_for_source(source_id)
by_path = {p["path"]: p for p in pages}
assert by_path["/a.md"]["content"] == "The end.\n\nThe end. More"
def test_long_overlap_trimmed_between_chunks(
self, pg_conn, patch_worker_db, task_self, monkeypatch
):
from application import worker
overlap = "x" * 40
source_id = _seed_source(pg_conn)
_patch_store(
monkeypatch,
[
_chunk("head " + overlap, source="a.md", doc_id="a1"),
_chunk(overlap + " tail", source="a.md", doc_id="a2"),
],
)
_patch_reembed(monkeypatch)
worker.convert_source_to_wiki_worker(task_self, source_id, "alice")
pages = WikiPagesRepository(pg_conn).list_for_source(source_id)
by_path = {p["path"]: p for p in pages}
assert by_path["/a.md"]["content"] == "head " + overlap + "\n\n tail"
def test_no_chunks_leaves_kind_and_structure(
self, pg_conn, patch_worker_db, task_self, monkeypatch
):
from application import worker
from application.storage.db.source_config import SourceConfig
original_structure = {"a.md": {"type": "text/markdown", "size_bytes": 1}}
source_id = _seed_source(pg_conn)
_patch_store(monkeypatch, [])
rebuild = MagicMock(name="rebuild")
monkeypatch.setattr(worker, "rebuild_wiki_directory_structure", rebuild)
delay = _patch_reembed(monkeypatch)
result = worker.convert_source_to_wiki_worker(task_self, source_id, "alice")
assert result["status"] == "no_pages"
assert result["pages_created"] == 0
rebuild.assert_not_called()
delay.assert_not_called()
refreshed = SourcesRepository(pg_conn).get_any(source_id, "alice")
assert SourceConfig.parse(refreshed.get("config")).kind != "wiki"
assert refreshed.get("directory_structure") == original_structure
def test_missing_path_chunk_skipped(
self, pg_conn, patch_worker_db, task_self, monkeypatch
):
from application import worker
source_id = _seed_source(pg_conn)
_patch_store(
monkeypatch,
[
_chunk("good", source="ok.md"),
_chunk("orphan", source=None),
],
)
_patch_reembed(monkeypatch)
result = worker.convert_source_to_wiki_worker(task_self, source_id, "alice")
assert result["pages_created"] == 1
pages = WikiPagesRepository(pg_conn).list_for_source(source_id)
assert {p["path"] for p in pages} == {"/ok.md"}
assert any(s["reason"] == "missing path" for s in result["skipped"])
def test_invalid_path_chunk_skipped(
self, pg_conn, patch_worker_db, task_self, monkeypatch
):
from application import worker
source_id = _seed_source(pg_conn)
_patch_store(
monkeypatch,
[
_chunk("good", source="ok.md"),
_chunk("bad", source="../evil.md"),
],
)
_patch_reembed(monkeypatch)
result = worker.convert_source_to_wiki_worker(task_self, source_id, "alice")
assert result["pages_created"] == 1
pages = WikiPagesRepository(pg_conn).list_for_source(source_id)
assert {p["path"] for p in pages} == {"/ok.md"}
skipped = {(s["file"], s["reason"]) for s in result["skipped"]}
assert ("../evil.md", "invalid path") in skipped
def test_path_falls_back_to_filename_then_title(
self, pg_conn, patch_worker_db, task_self, monkeypatch
):
from application import worker
source_id = _seed_source(pg_conn)
_patch_store(
monkeypatch,
[
_chunk("from filename", source=None, filename="byname.md"),
_chunk("from title", source=None, title="bytitle.md"),
],
)
_patch_reembed(monkeypatch)
result = worker.convert_source_to_wiki_worker(task_self, source_id, "alice")
assert result["pages_created"] == 2
pages = WikiPagesRepository(pg_conn).list_for_source(source_id)
assert {p["path"] for p in pages} == {"/byname.md", "/bytitle.md"}
def test_crawler_chunk_uses_file_path_not_url(
self, pg_conn, patch_worker_db, task_self, monkeypatch
):
from application import worker
source_id = _seed_source(pg_conn)
_patch_store(
monkeypatch,
[
_chunk(
"setup body",
source="https://docs.x.com/guides/setup",
file_path="guides/setup.md",
),
],
)
_patch_reembed(monkeypatch)
result = worker.convert_source_to_wiki_worker(task_self, source_id, "alice")
assert result["status"] == "converted"
assert result["skipped"] == []
pages = WikiPagesRepository(pg_conn).list_for_source(source_id)
assert {p["path"] for p in pages} == {"/guides/setup.md"}
def test_connector_chunks_kept_separate_by_file_name(
self, pg_conn, patch_worker_db, task_self, monkeypatch
):
from application import worker
source_id = _seed_source(pg_conn)
_patch_store(
monkeypatch,
[
_chunk("page a", source="confluence", file_name="Page A", doc_id="a"),
_chunk("page b", source="confluence", file_name="Page B", doc_id="b"),
],
)
_patch_reembed(monkeypatch)
result = worker.convert_source_to_wiki_worker(task_self, source_id, "alice")
assert result["pages_created"] == 2
pages = WikiPagesRepository(pg_conn).list_for_source(source_id)
assert {p["path"] for p in pages} == {"/Page A", "/Page B"}
def test_url_only_chunk_normalized_not_skipped(
self, pg_conn, patch_worker_db, task_self, monkeypatch
):
from application import worker
source_id = _seed_source(pg_conn)
_patch_store(
monkeypatch,
[_chunk("body", source="https://x.com/a/b")],
)
_patch_reembed(monkeypatch)
result = worker.convert_source_to_wiki_worker(task_self, source_id, "alice")
assert result["status"] == "converted"
assert result["skipped"] == []
pages = WikiPagesRepository(pg_conn).list_for_source(source_id)
assert {p["path"] for p in pages} == {"/a/b"}
def test_already_wiki_returns_early(
self, pg_conn, patch_worker_db, task_self, monkeypatch
):
from application import worker
source_id = _seed_source(pg_conn, config={"kind": "wiki"})
store = _patch_store(monkeypatch, [_chunk("body", source="a.md")])
delay = _patch_reembed(monkeypatch)
result = worker.convert_source_to_wiki_worker(task_self, source_id, "alice")
assert result["status"] == "already_wiki"
store.get_chunks.assert_not_called()
delay.assert_not_called()