docling-studio/document-parser/tests/test_migrate_06.py
Pier-Jean Malandrino f85195be0c chore(#206): backfill 0.6.0 doc-centric data model
CLI script to migrate existing tenants to the data model introduced
by #202-205. Idempotent and resumable — re-running is a no-op.

tools/migrate_06.py
- Step 1: backfill_lifecycle — infers documents.lifecycle_state from
  analysis_jobs.status + chunks_json presence
    Failed analyses, no completed       -> Failed
    Completed + chunks_json             -> Chunked
    Completed, no chunks_json           -> Parsed
    Otherwise                           -> Uploaded
- Step 2: materialize_chunks — promotes the latest chunks_json blob
  for each doc into rows in the new chunks table. Stable id derivation
  via uuid5(NAMESPACE_OID, '<doc>|<seq>|<text>') so re-running lands
  on the same ids
- Step 3: backfill_links — for any doc that has chunks materialized,
  creates a (doc, default-store) link in Ingested state with a freshly
  computed chunkset_hash. Treats 'chunks exist' as proxy for 'doc was
  ingested into the legacy single index' — sufficient for the typical
  pre-0.6.0 deployment, with OpenSearch reindex documented separately
- Step 4: reaggregate — applies #203's aggregate_lifecycle rule so
  doc-chunked transitions to Ingested

Persistence
- migration_progress table for resumability + per-step idempotency

CLI
    python -m tools.migrate_06              # full migration
    python -m tools.migrate_06 --dry-run    # plan only, no writes
    python -m tools.migrate_06 --only-step <name>   # rerun a phase

Tests
- 9 tests: inference rule per (completed, failed, chunks_json) tuple,
  end-to-end migration on a hand-built three-doc snapshot, idempotency
  (second run = no writes), --dry-run writes nothing, deterministic
  chunk ids across reruns

Refs #206
2026-04-29 17:10:44 +02:00

185 lines
6.7 KiB
Python

"""Tests for the 0.6.0 migration script (#206)."""
from __future__ import annotations
import json
import pytest
from domain.value_objects import DocumentLifecycleState, DocumentStoreLinkState
from persistence.chunk_repo import SqliteChunkRepository
from persistence.database import get_connection, init_db
from persistence.document_repo import SqliteDocumentRepository
from persistence.document_store_link_repo import SqliteDocumentStoreLinkRepository
from tools.migrate_06 import _DocFacts, _infer_lifecycle, run
@pytest.fixture(autouse=True)
async def setup_db(monkeypatch, tmp_path):
db_path = str(tmp_path / "test.db")
monkeypatch.setattr("persistence.database.DB_PATH", db_path)
await init_db()
yield
# ---------------------------------------------------------------------------
# Inference rule unit tests
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
("has_completed", "has_failed", "has_chunks_json", "expected"),
[
(False, False, False, DocumentLifecycleState.UPLOADED),
(False, True, False, DocumentLifecycleState.FAILED),
(True, False, False, DocumentLifecycleState.PARSED),
(True, False, True, DocumentLifecycleState.CHUNKED),
(True, True, True, DocumentLifecycleState.CHUNKED), # any completed wins
],
)
def test_infer_lifecycle(
has_completed: bool,
has_failed: bool,
has_chunks_json: bool,
expected: DocumentLifecycleState,
) -> None:
facts = _DocFacts(
document_id="doc",
has_completed=has_completed,
has_failed=has_failed,
has_chunks_json=has_chunks_json,
)
assert _infer_lifecycle(facts) == expected
# ---------------------------------------------------------------------------
# Integration: end-to-end migration on a hand-built pre-state
# ---------------------------------------------------------------------------
async def _seed_pre_06_state() -> None:
"""Seed a tenant snapshot with three documents covering the three
main inference cases:
doc-uploaded — only an `Uploaded` doc row, nothing else
doc-parsed — completed analysis, no chunks
doc-chunked — completed analysis with chunks_json
"""
repo = SqliteDocumentRepository()
async with get_connection() as db:
await db.execute(
"INSERT INTO documents (id, filename, storage_path, created_at) "
"VALUES ('doc-uploaded', 'a.pdf', '/tmp/a.pdf', datetime('now'))"
)
await db.execute(
"INSERT INTO documents (id, filename, storage_path, created_at) "
"VALUES ('doc-parsed', 'b.pdf', '/tmp/b.pdf', datetime('now'))"
)
await db.execute(
"INSERT INTO documents (id, filename, storage_path, created_at) "
"VALUES ('doc-chunked', 'c.pdf', '/tmp/c.pdf', datetime('now'))"
)
await db.execute(
"INSERT INTO analysis_jobs (id, document_id, status) "
"VALUES ('a-parsed', 'doc-parsed', 'COMPLETED')"
)
chunks_json = json.dumps(
[
{"text": "Intro paragraph.", "headings": ["Intro"], "sourcePage": 1},
{"text": "Body of section A.", "headings": ["A"], "sourcePage": 2},
]
)
await db.execute(
"INSERT INTO analysis_jobs (id, document_id, status, chunks_json) "
"VALUES ('a-chunked', 'doc-chunked', 'COMPLETED', ?)",
(chunks_json,),
)
await db.commit()
# Touch the document repo so the type checker is satisfied.
assert await repo.find_by_id("doc-chunked") is not None
async def test_full_migration_writes_expected_state() -> None:
await _seed_pre_06_state()
results = await run(dry_run=False)
by_step = {r.name: r for r in results}
assert by_step["backfill_lifecycle"].written >= 2 # parsed + chunked transition
assert by_step["materialize_chunks"].written == 2 # two chunks
assert by_step["backfill_links"].written == 1 # only doc-chunked has chunks
repo = SqliteDocumentRepository()
chunks_repo = SqliteChunkRepository()
link_repo = SqliteDocumentStoreLinkRepository()
uploaded = await repo.find_by_id("doc-uploaded")
assert uploaded is not None
assert uploaded.lifecycle_state == DocumentLifecycleState.UPLOADED
parsed = await repo.find_by_id("doc-parsed")
assert parsed is not None
assert parsed.lifecycle_state == DocumentLifecycleState.PARSED
chunked = await repo.find_by_id("doc-chunked")
assert chunked is not None
# After link backfill + reaggregation, doc-chunked is Ingested in default store.
assert chunked.lifecycle_state == DocumentLifecycleState.INGESTED
chunks = await chunks_repo.find_for_document("doc-chunked")
assert len(chunks) == 2
assert chunks[0].text == "Intro paragraph."
assert chunks[0].sequence == 0
links = await link_repo.find_for_document("doc-chunked")
assert len(links) == 1
assert links[0].store_id == "default"
assert links[0].state == DocumentStoreLinkState.INGESTED
assert links[0].chunkset_hash is not None
async def test_migration_is_idempotent() -> None:
await _seed_pre_06_state()
first = await run(dry_run=False)
second = await run(dry_run=False)
# Second run sees every step as already-done.
assert all(r.skipped for r in second)
assert all(not r.skipped for r in first)
async def test_dry_run_writes_nothing() -> None:
await _seed_pre_06_state()
repo = SqliteDocumentRepository()
chunks_repo = SqliteChunkRepository()
await run(dry_run=True)
# No state changed.
parsed = await repo.find_by_id("doc-parsed")
assert parsed is not None
assert parsed.lifecycle_state == DocumentLifecycleState.UPLOADED # unchanged
chunked_chunks = await chunks_repo.find_for_document("doc-chunked")
assert chunked_chunks == []
async def test_chunk_ids_are_deterministic_across_runs() -> None:
"""Stable id derivation lets a re-run after a manual reset land on
the same chunk ids — important for the link-table foreign keys."""
await _seed_pre_06_state()
chunks_repo = SqliteChunkRepository()
await run(dry_run=False, only_step="materialize_chunks")
first_ids = sorted(c.id for c in await chunks_repo.find_for_document("doc-chunked"))
# Wipe progress + chunks, run again — same ids.
async with get_connection() as db:
await db.execute("DELETE FROM migration_progress WHERE name = 'materialize_chunks'")
await db.execute("DELETE FROM chunks WHERE document_id = 'doc-chunked'")
await db.commit()
await run(dry_run=False, only_step="materialize_chunks")
second_ids = sorted(c.id for c in await chunks_repo.find_for_document("doc-chunked"))
assert first_ids == second_ids