feat: garde-fous d'écriture des classeurs Excel #153 (P0)
L'édition d'un .xlsx pouvait détruire une partie du classeur, le concurrencer en silence, ou diffuser une injection de formule. - BUG-085 : inspect_workbook() détecte ce qu'un round-trip openpyxl perd (valeurs calculées en cache, slicers, contrôles, connexions, custom XML, signature, commentaires enrichis, macros) → xlsx_lossy_features exposé en lecture, bandeau FR/EN, et 409 xlsx_lossy_content sans `force` (confirmation explicite puis reprise). Périmètre réel revalidé : graphiques, images et TCD survivent au round-trip. - BUG-086 : écriture atomique (fichier .tmp + os.replace) : un plantage ne peut plus tronquer le classeur, le backup reste intact. - BUG-087 : verrou par fichier autour du read-modify-write (timeout 15 s, 409 conflict) ; endpoint xlsx/save devenu synchrone pour que l'attente s'exécute dans le threadpool. - BUG-088 : une saisie en '=' ou '@' est stockée en texte, sauf opt-in `allow_formula` ou le bouton f(x) de la visionneuse. Le handler ServiceError expose désormais code + details, que api() propage. - BUG-084 : la suppression d'une vault purge enfin l'index inversé (documents fantômes qui continuaient de matcher) et is_stale() devient is_ready(), le nom étant trompeur (la staleness n'existe plus). Tests : 1390 pytest, 10 JSDOM (xlsx-viewer.test.mjs, branché au CI), 3 E2E Playwright, suite E2E complète verte, ruff/mypy 0. 🤖 Generated with Codebuff Co-Authored-By: Codebuff <[email protected]>
This commit is contained in:
@@ -115,6 +115,25 @@ class TestInvertedIndex:
|
||||
inv.remove_document("V", "p.md")
|
||||
assert inv.doc_count == 0 # Skipped
|
||||
|
||||
def test_is_ready_tracks_initial_build(self, client):
|
||||
"""is_ready() is the only freshness signal: no generation counter,
|
||||
no cooldown, no lazy rebuild (plan.md step 6)."""
|
||||
inv = InvertedIndex()
|
||||
assert inv.is_ready() is False
|
||||
inv.rebuild()
|
||||
assert inv.is_ready() is True
|
||||
# The old staleness API is gone for good.
|
||||
assert not hasattr(inv, "is_stale")
|
||||
|
||||
def test_is_ready_survives_incremental_updates(self, client):
|
||||
"""Incremental add/remove must not flip readiness back (which would
|
||||
silently push search() onto the O(N) full-scan fallback)."""
|
||||
self.inv.rebuild()
|
||||
assert self.inv.is_ready() is True
|
||||
self.inv.add_document("V", "p.md", {"path": "p.md", "title": "T", "tags": [], "content": "x"})
|
||||
self.inv.remove_document("V", "p.md")
|
||||
assert self.inv.is_ready() is True
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# Search / Advanced Search integration tests
|
||||
@@ -192,3 +211,40 @@ class TestSearchFunctions:
|
||||
def test_suggest_tags_no_match(self, client):
|
||||
suggestions = suggest_tags("xyznonexistent", vault_filter="all")
|
||||
assert len(suggestions) == 0
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# Vault removal — inverted index must not keep ghost documents
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
class TestVaultRemovalPurgesInvertedIndex:
|
||||
"""`remove_vault_from_index()` must notify the inverted-index hook.
|
||||
|
||||
Regression: it only cleaned the indexer's own structures, so every document
|
||||
of the removed vault survived in the inverted index (postings, doc_info,
|
||||
doc_vault, vault_docs) and kept matching searches for a vault that no
|
||||
longer exists — a leak that only a manual reindex used to clear.
|
||||
"""
|
||||
|
||||
def test_removing_a_vault_purges_its_documents(self, client):
|
||||
import asyncio
|
||||
|
||||
import backend.indexer as ix
|
||||
import backend.search as bs
|
||||
|
||||
ix.set_index_change_hook(bs._on_index_change_hook)
|
||||
inv = bs._inverted_index
|
||||
inv.rebuild()
|
||||
|
||||
vault_keys = [k for k in inv.doc_info if k.startswith("TestVault::")]
|
||||
assert vault_keys, "vault non indexe, test sans valeur"
|
||||
before = inv.doc_count
|
||||
|
||||
asyncio.run(ix.remove_vault_from_index("TestVault"))
|
||||
|
||||
ghosts = [k for k in inv.doc_info if k.startswith("TestVault::")]
|
||||
assert not ghosts, f"documents fantomes dans l'index inverse : {ghosts[:5]}"
|
||||
assert inv.doc_count == before - len(vault_keys)
|
||||
assert "TestVault" not in inv.vault_docs
|
||||
# The index stays usable for the remaining vaults.
|
||||
assert inv.is_ready() is True
|
||||
|
||||
Reference in New Issue
Block a user