2026-04-06 22:51:04 +08:00
|
|
|
import pytest
|
|
|
|
|
from pageindex.storage.sqlite import SQLiteStorage
|
|
|
|
|
|
|
|
|
|
@pytest.fixture
|
|
|
|
|
def storage(tmp_path):
|
|
|
|
|
return SQLiteStorage(str(tmp_path / "test.db"))
|
|
|
|
|
|
|
|
|
|
def test_create_and_list_collections(storage):
|
|
|
|
|
storage.create_collection("papers")
|
|
|
|
|
assert "papers" in storage.list_collections()
|
|
|
|
|
|
|
|
|
|
def test_get_or_create_collection_idempotent(storage):
|
|
|
|
|
storage.get_or_create_collection("papers")
|
|
|
|
|
storage.get_or_create_collection("papers")
|
|
|
|
|
assert storage.list_collections().count("papers") == 1
|
|
|
|
|
|
|
|
|
|
def test_delete_collection(storage):
|
|
|
|
|
storage.create_collection("papers")
|
|
|
|
|
storage.delete_collection("papers")
|
|
|
|
|
assert "papers" not in storage.list_collections()
|
|
|
|
|
|
fix: CMYK image drop, empty-doc crash, page_index shadowing, sqlite hardening, flaky tests
Addresses items 9-13 and a/b/c/f from the max-effort review of PR #272.
- pdf.py: image colorspace check was `pix.n > 4`, which treats CMYK-without-
alpha (n==4, same as RGBA) as not needing RGB conversion; pix.save() as .png
then raises "unsupported colorspace", silently dropped by the surrounding
except. Fixed to `pix.n - pix.alpha >= 4` (correctly converts CMYK, leaves
RGBA untouched).
- pipeline.py: detect_strategy([]) (an empty/whitespace-only source file)
returned "content_based", routing into the PDF-oriented TOC-detection
pipeline -- wasting a real LLM call before raising IndexingError. Empty
node lists now route to level_based, whose build_tree_from_levels([])
returns an empty structure instantly with zero LLM calls.
- page_index.py (shim): pageindex/__init__.py binds the canonical `page_index`
function as the package attribute, but this file is ALSO a real submodule
of the same name -- importing it anywhere (import machinery, unconditional)
overwrites that attribute with the module object, breaking
`from pageindex import page_index; page_index(x)` for the rest of the
process. Made the shim module itself callable (delegates to the real
function via a ModuleType subclass), so whichever object ends up in that
slot is callable regardless of import order.
- storage/sqlite.py: create_collection let a raw sqlite3.IntegrityError escape
on a duplicate name (new CollectionAlreadyExistsError); the collections
table's CHECK constraint only validated the name's first character (GLOB
'*' is a wildcard, not a regex quantifier over the preceding class) --
fixed to validate the whole string, and SQLiteStorage now also validates in
Python (it's a public StorageEngine usable directly, bypassing
LocalBackend's own check).
- tests/test_review_fixes_2.py: two tests used a ContentNode with no `level`
set, so build_index took the content_based path and made real (retried,
slow, and -- with a valid key -- billable) LLM calls instead of testing the
text-stripping logic they claimed to. Mocked out _content_based_pipeline.
- retrieve.py: _parse_pages/_get_pdf_page_content were independent copies of
the canonical parse_pages/get_pdf_page_content that had already drifted
(missing the p>=1 filter and 1000-page DoS cap) -- delegate to canonical
now, so the legacy pageindex.get_page_content path can't silently regress
again.
- parser/markdown.py: a leading UTF-8 BOM broke first-header detection
(not whitespace, .strip() doesn't remove it) -- decode utf-8-sig. Only
backtick fences were recognized as code blocks, so a '#'-prefixed line
inside a ~~~-fenced block (valid CommonMark) was misparsed as a heading --
recognize both fence styles.
- run_pageindex.py: --if-thinning wasn't migrated to the bare-flag +
legacy-yes/no convention the other four --if-add-* flags got; bare usage
raised an argparse error and it never went through the shared coercion.
- types.py: DocumentDetail's `structure` field was inside the class's
total=False body, so TypedDict rules made it optional even though every
backend always populates it. Split into a required base class.
Adds regression tests for all of the above. Full suite: 244 passed, 2 skipped
(one pre-existing, unrelated flaky cloud-streaming test).
Claude-Session: https://claude.ai/code/session_01Kx5DgKbhK1N8autqXH8SmS
2026-07-09 11:58:59 +08:00
|
|
|
|
|
|
|
|
def test_create_duplicate_collection_raises_pageindex_error(storage):
|
|
|
|
|
"""A raw sqlite3.IntegrityError leaking out breaks `except PageIndexError`
|
|
|
|
|
catch-alls; must be translated to a proper SDK exception."""
|
|
|
|
|
from pageindex.errors import CollectionAlreadyExistsError, PageIndexError
|
|
|
|
|
storage.create_collection("papers")
|
|
|
|
|
with pytest.raises(CollectionAlreadyExistsError):
|
|
|
|
|
storage.create_collection("papers")
|
|
|
|
|
# also catchable via the SDK's generic base class
|
|
|
|
|
storage.create_collection("other")
|
|
|
|
|
with pytest.raises(PageIndexError):
|
|
|
|
|
storage.create_collection("other")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.parametrize("bad_name", [
|
|
|
|
|
"a/../../etc/passwd", "/etc/passwd", "a$(whoami)", ".hidden",
|
|
|
|
|
"a b", "válid", "", "a" * 129,
|
2026-07-09 19:12:56 +08:00
|
|
|
# A trailing newline must be rejected: Python's $ matches just before a
|
|
|
|
|
# final \n, so a $-anchored .match() would let "papers\n" slip through
|
|
|
|
|
# (then INSERT OR IGNORE silently no-ops on the SQL CHECK).
|
|
|
|
|
"papers\n", "\npapers", "papers\n\n",
|
fix: CMYK image drop, empty-doc crash, page_index shadowing, sqlite hardening, flaky tests
Addresses items 9-13 and a/b/c/f from the max-effort review of PR #272.
- pdf.py: image colorspace check was `pix.n > 4`, which treats CMYK-without-
alpha (n==4, same as RGBA) as not needing RGB conversion; pix.save() as .png
then raises "unsupported colorspace", silently dropped by the surrounding
except. Fixed to `pix.n - pix.alpha >= 4` (correctly converts CMYK, leaves
RGBA untouched).
- pipeline.py: detect_strategy([]) (an empty/whitespace-only source file)
returned "content_based", routing into the PDF-oriented TOC-detection
pipeline -- wasting a real LLM call before raising IndexingError. Empty
node lists now route to level_based, whose build_tree_from_levels([])
returns an empty structure instantly with zero LLM calls.
- page_index.py (shim): pageindex/__init__.py binds the canonical `page_index`
function as the package attribute, but this file is ALSO a real submodule
of the same name -- importing it anywhere (import machinery, unconditional)
overwrites that attribute with the module object, breaking
`from pageindex import page_index; page_index(x)` for the rest of the
process. Made the shim module itself callable (delegates to the real
function via a ModuleType subclass), so whichever object ends up in that
slot is callable regardless of import order.
- storage/sqlite.py: create_collection let a raw sqlite3.IntegrityError escape
on a duplicate name (new CollectionAlreadyExistsError); the collections
table's CHECK constraint only validated the name's first character (GLOB
'*' is a wildcard, not a regex quantifier over the preceding class) --
fixed to validate the whole string, and SQLiteStorage now also validates in
Python (it's a public StorageEngine usable directly, bypassing
LocalBackend's own check).
- tests/test_review_fixes_2.py: two tests used a ContentNode with no `level`
set, so build_index took the content_based path and made real (retried,
slow, and -- with a valid key -- billable) LLM calls instead of testing the
text-stripping logic they claimed to. Mocked out _content_based_pipeline.
- retrieve.py: _parse_pages/_get_pdf_page_content were independent copies of
the canonical parse_pages/get_pdf_page_content that had already drifted
(missing the p>=1 filter and 1000-page DoS cap) -- delegate to canonical
now, so the legacy pageindex.get_page_content path can't silently regress
again.
- parser/markdown.py: a leading UTF-8 BOM broke first-header detection
(not whitespace, .strip() doesn't remove it) -- decode utf-8-sig. Only
backtick fences were recognized as code blocks, so a '#'-prefixed line
inside a ~~~-fenced block (valid CommonMark) was misparsed as a heading --
recognize both fence styles.
- run_pageindex.py: --if-thinning wasn't migrated to the bare-flag +
legacy-yes/no convention the other four --if-add-* flags got; bare usage
raised an argparse error and it never went through the shared coercion.
- types.py: DocumentDetail's `structure` field was inside the class's
total=False body, so TypedDict rules made it optional even though every
backend always populates it. Split into a required base class.
Adds regression tests for all of the above. Full suite: 244 passed, 2 skipped
(one pre-existing, unrelated flaky cloud-streaming test).
Claude-Session: https://claude.ai/code/session_01Kx5DgKbhK1N8autqXH8SmS
2026-07-09 11:58:59 +08:00
|
|
|
])
|
|
|
|
|
def test_create_collection_rejects_invalid_names_at_the_python_layer(storage, bad_name):
|
|
|
|
|
"""SQLiteStorage must validate collection names itself — it's a public
|
|
|
|
|
StorageEngine that can be used directly, bypassing LocalBackend's own
|
|
|
|
|
regex check entirely."""
|
|
|
|
|
from pageindex.errors import PageIndexError
|
|
|
|
|
with pytest.raises(PageIndexError):
|
|
|
|
|
storage.create_collection(bad_name)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_sql_check_constraint_also_rejects_invalid_names_directly(storage):
|
|
|
|
|
"""Defense-in-depth: even bypassing SQLiteStorage's own Python validation
|
|
|
|
|
and inserting via raw SQL, the schema's CHECK constraint must reject a
|
|
|
|
|
name that isn't ENTIRELY [a-zA-Z0-9_-] — not just its first character
|
|
|
|
|
(GLOB '*' is a wildcard, not a regex quantifier over the preceding class,
|
|
|
|
|
so 'name GLOB [a-zA-Z0-9_-]*' alone only constrains the first character)."""
|
|
|
|
|
import sqlite3
|
|
|
|
|
conn = storage._get_conn()
|
|
|
|
|
with pytest.raises(sqlite3.IntegrityError):
|
|
|
|
|
conn.execute("INSERT INTO collections (name) VALUES (?)", ("a/../../etc/passwd",))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_malicious_collection_name_rejected_through_local_backend_too(tmp_path):
|
|
|
|
|
"""End-to-end via the normal LocalBackend entry point: a path-traversal-
|
|
|
|
|
shaped collection name must never reach add_document's
|
|
|
|
|
files_dir / collection path construction. Three independent layers now
|
|
|
|
|
reject it (LocalBackend's own regex, SQLiteStorage's regex, and the SQL
|
|
|
|
|
CHECK constraint) — this pins the outermost one."""
|
|
|
|
|
from pageindex.backend.local import LocalBackend
|
|
|
|
|
from pageindex.errors import PageIndexError
|
|
|
|
|
|
|
|
|
|
storage = SQLiteStorage(str(tmp_path / "t.db"))
|
|
|
|
|
backend = LocalBackend(storage=storage, files_dir=str(tmp_path / "files"), model="gpt-4o")
|
|
|
|
|
with pytest.raises(PageIndexError):
|
|
|
|
|
backend.create_collection("a/../../escape_me")
|
|
|
|
|
assert not (tmp_path / "escape_me").exists()
|
|
|
|
|
|
2026-04-06 22:51:04 +08:00
|
|
|
def test_save_and_get_document(storage):
|
|
|
|
|
storage.create_collection("papers")
|
|
|
|
|
doc = {
|
|
|
|
|
"doc_name": "test.pdf", "doc_description": "A test",
|
|
|
|
|
"file_path": "/tmp/test.pdf", "doc_type": "pdf",
|
|
|
|
|
"structure": [{"title": "Intro", "node_id": "0001"}],
|
|
|
|
|
}
|
|
|
|
|
storage.save_document("papers", "doc-1", doc)
|
|
|
|
|
result = storage.get_document("papers", "doc-1")
|
|
|
|
|
assert result["doc_name"] == "test.pdf"
|
|
|
|
|
assert result["doc_type"] == "pdf"
|
|
|
|
|
|
|
|
|
|
def test_get_document_structure(storage):
|
|
|
|
|
storage.create_collection("papers")
|
|
|
|
|
structure = [{"title": "Ch1", "node_id": "0001", "nodes": []}]
|
|
|
|
|
storage.save_document("papers", "doc-1", {
|
|
|
|
|
"doc_name": "test.pdf", "doc_type": "pdf",
|
|
|
|
|
"file_path": "/tmp/test.pdf", "structure": structure,
|
|
|
|
|
})
|
|
|
|
|
result = storage.get_document_structure("papers", "doc-1")
|
|
|
|
|
assert result[0]["title"] == "Ch1"
|
|
|
|
|
|
|
|
|
|
def test_list_documents(storage):
|
|
|
|
|
storage.create_collection("papers")
|
|
|
|
|
storage.save_document("papers", "doc-1", {"doc_name": "p1.pdf", "doc_type": "pdf", "file_path": "/tmp/p1.pdf", "structure": []})
|
|
|
|
|
storage.save_document("papers", "doc-2", {"doc_name": "p2.pdf", "doc_type": "pdf", "file_path": "/tmp/p2.pdf", "structure": []})
|
|
|
|
|
docs = storage.list_documents("papers")
|
|
|
|
|
assert len(docs) == 2
|
|
|
|
|
|
|
|
|
|
def test_delete_document(storage):
|
|
|
|
|
storage.create_collection("papers")
|
|
|
|
|
storage.save_document("papers", "doc-1", {"doc_name": "test.pdf", "doc_type": "pdf", "file_path": "/tmp/test.pdf", "structure": []})
|
|
|
|
|
storage.delete_document("papers", "doc-1")
|
|
|
|
|
assert len(storage.list_documents("papers")) == 0
|
|
|
|
|
|
|
|
|
|
def test_delete_collection_cascades_documents(storage):
|
|
|
|
|
storage.create_collection("papers")
|
|
|
|
|
storage.save_document("papers", "doc-1", {"doc_name": "test.pdf", "doc_type": "pdf", "file_path": "/tmp/test.pdf", "structure": []})
|
|
|
|
|
storage.delete_collection("papers")
|
|
|
|
|
assert "papers" not in storage.list_collections()
|
2026-07-07 10:12:51 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_close_closes_connections_created_in_other_threads(storage):
|
|
|
|
|
"""Regression: with check_same_thread=True, close() from another thread
|
|
|
|
|
raised ProgrammingError (swallowed) and leaked every worker connection."""
|
|
|
|
|
import sqlite3
|
|
|
|
|
import threading
|
|
|
|
|
|
|
|
|
|
conns = {}
|
|
|
|
|
|
|
|
|
|
def worker():
|
|
|
|
|
conns["worker"] = storage._get_conn()
|
|
|
|
|
|
|
|
|
|
t = threading.Thread(target=worker)
|
|
|
|
|
t.start()
|
|
|
|
|
t.join()
|
|
|
|
|
|
|
|
|
|
storage.close() # main thread closes the worker's connection too
|
|
|
|
|
with pytest.raises(sqlite3.ProgrammingError):
|
|
|
|
|
conns["worker"].execute("SELECT 1")
|
fix: dedup race, cwd-relative image paths, unencoded legacy URLs
- SQLite dedup race: add UNIQUE(collection_name, file_hash) and switch
save_document to a plain INSERT. add_document now catches the
IntegrityError from a concurrent add of the same content, cleans up its
managed files, and returns the winning doc_id — instead of two doc_ids
for one file (each having paid for its own LLM indexing).
- PDF image paths: store the absolute path to each extracted image
instead of a path relative to the indexing process's cwd. The
 references broke as soon as a query ran from a different
directory.
- LegacyCloudAPI: URL-encode doc_id / retrieval_id path segments (added
_enc()), matching CloudBackend. An id containing '/', '?', '#' or a
space previously hit the wrong endpoint or produced a malformed URL.
Adds regression tests: UNIQUE enforcement, the add-race resolving to the
winner, absolute image paths, and encoded legacy URLs.
Claude-Session: https://claude.ai/code/session_01Kx5DgKbhK1N8autqXH8SmS
2026-07-07 12:06:58 +08:00
|
|
|
|
|
|
|
|
|
2026-07-10 14:49:55 +08:00
|
|
|
def test_worker_reconnects_via_get_conn_after_close(storage):
|
|
|
|
|
"""Regression: after close(), a thread that had already cached a connection
|
|
|
|
|
in thread-local storage would get that now-CLOSED handle back from
|
|
|
|
|
_get_conn (close() can only del its own thread-local), raising
|
|
|
|
|
ProgrammingError instead of transparently reconnecting. A generation bump
|
|
|
|
|
on close() must make the SAME thread's next _get_conn hand back a fresh,
|
|
|
|
|
working connection."""
|
|
|
|
|
import threading
|
|
|
|
|
|
|
|
|
|
storage.create_collection("papers")
|
|
|
|
|
|
|
|
|
|
cached = threading.Event()
|
|
|
|
|
closed = threading.Event()
|
|
|
|
|
result = {}
|
|
|
|
|
|
|
|
|
|
def worker():
|
|
|
|
|
# 1. cache a connection in this thread's thread-local
|
|
|
|
|
storage._get_conn().execute("SELECT 1")
|
|
|
|
|
cached.set()
|
|
|
|
|
# 2. wait until the main thread closed the storage (invalidating it)
|
|
|
|
|
closed.wait(timeout=5)
|
|
|
|
|
# 3. reuse from the SAME thread -> must reconnect, not reuse closed conn
|
|
|
|
|
try:
|
|
|
|
|
result["val"] = storage._get_conn().execute("SELECT 1").fetchone()[0]
|
|
|
|
|
result["list"] = storage.list_collections()
|
|
|
|
|
except Exception as e: # noqa: BLE001 - record for assertion
|
|
|
|
|
result["err"] = f"{type(e).__name__}: {e}"
|
|
|
|
|
|
|
|
|
|
t = threading.Thread(target=worker)
|
|
|
|
|
t.start()
|
|
|
|
|
cached.wait(timeout=5)
|
|
|
|
|
storage.close() # closes + invalidates the worker's cached connection
|
|
|
|
|
closed.set()
|
|
|
|
|
t.join(timeout=5)
|
|
|
|
|
|
|
|
|
|
assert "err" not in result, f"reconnect after close failed: {result.get('err')}"
|
|
|
|
|
assert result["val"] == 1
|
|
|
|
|
assert result["list"] == ["papers"]
|
|
|
|
|
|
|
|
|
|
|
fix: dedup race, cwd-relative image paths, unencoded legacy URLs
- SQLite dedup race: add UNIQUE(collection_name, file_hash) and switch
save_document to a plain INSERT. add_document now catches the
IntegrityError from a concurrent add of the same content, cleans up its
managed files, and returns the winning doc_id — instead of two doc_ids
for one file (each having paid for its own LLM indexing).
- PDF image paths: store the absolute path to each extracted image
instead of a path relative to the indexing process's cwd. The
 references broke as soon as a query ran from a different
directory.
- LegacyCloudAPI: URL-encode doc_id / retrieval_id path segments (added
_enc()), matching CloudBackend. An id containing '/', '?', '#' or a
space previously hit the wrong endpoint or produced a malformed URL.
Adds regression tests: UNIQUE enforcement, the add-race resolving to the
winner, absolute image paths, and encoded legacy URLs.
Claude-Session: https://claude.ai/code/session_01Kx5DgKbhK1N8autqXH8SmS
2026-07-07 12:06:58 +08:00
|
|
|
def test_duplicate_file_hash_in_collection_raises(storage):
|
|
|
|
|
"""UNIQUE(collection_name, file_hash) guards the add-same-file race."""
|
|
|
|
|
import sqlite3
|
|
|
|
|
storage.create_collection("papers")
|
|
|
|
|
doc = {"doc_name": "a", "doc_type": "pdf", "file_hash": "HASH1", "structure": []}
|
|
|
|
|
storage.save_document("papers", "doc-1", doc)
|
|
|
|
|
with pytest.raises(sqlite3.IntegrityError):
|
|
|
|
|
storage.save_document("papers", "doc-2", {**doc, "doc_name": "b"})
|
|
|
|
|
# same hash in a DIFFERENT collection is fine
|
|
|
|
|
storage.create_collection("other")
|
|
|
|
|
storage.save_document("other", "doc-3", {**doc})
|
2026-07-07 18:23:01 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_concurrent_read_then_write_no_database_locked(storage):
|
|
|
|
|
"""Regression: concurrent add (read hash -> write) hit 'database is locked'
|
|
|
|
|
under WAL. Fixed via autocommit + busy_timeout + write lock. All writers
|
|
|
|
|
must succeed (dedup via UNIQUE), none raise OperationalError."""
|
|
|
|
|
import sqlite3, threading, uuid, time
|
|
|
|
|
storage.create_collection("c")
|
|
|
|
|
errs = []
|
|
|
|
|
|
|
|
|
|
def worker():
|
|
|
|
|
try:
|
|
|
|
|
storage.list_collections()
|
|
|
|
|
storage.find_document_by_hash("c", "SAME") # read snapshot
|
|
|
|
|
time.sleep(0.001) # widen the window
|
|
|
|
|
try:
|
|
|
|
|
storage.save_document("c", str(uuid.uuid4()),
|
|
|
|
|
{"doc_name": "d", "doc_type": "pdf", "file_hash": "SAME", "structure": []})
|
|
|
|
|
except sqlite3.IntegrityError:
|
|
|
|
|
pass # expected: lost the dedup race
|
|
|
|
|
except Exception as e:
|
|
|
|
|
errs.append(f"{type(e).__name__}: {e}")
|
|
|
|
|
|
|
|
|
|
threads = [threading.Thread(target=worker) for _ in range(12)]
|
|
|
|
|
[t.start() for t in threads]; [t.join() for t in threads]
|
|
|
|
|
assert not errs, f"concurrent write errored: {errs}"
|
|
|
|
|
assert len(storage.list_documents("c")) == 1 # dedup held
|