Files
AetherTable/python/tests/test_foundry_importer.py
John SmithandClaude 31a0e1783a fix(gateway): cumulative zip budget + importer path containment (iteration 42)
Closes A3 #1/#2: one shared extraction budget draws down across all
entries (mid-copy cutoff), declared-size sum refused pre-extraction,
chunked streaming upload caps memory on lying Content-Length; manifest
pack paths contained via commonpath with skip-with-warning. Lying
comments corrected. 8 tests red-first incl. a lying-reader seam for
the zipfile declared-size cap; pytest 812 passed / 29 skipped.

Co-Authored-By: Claude <[email protected]>
2026-08-25 10:26:24 -04:00

497 lines
19 KiB
Python

"""Tests for the Foundry VTT module importer (Pillar 10 interop).
Fixtures are crafted in-test to mirror the REAL Foundry module layout:
a ``module.json`` manifest plus ``packs/*.db`` files where every line is one
JSON document (the documented "Export Pack" NDJSON format).
"""
import json
import os
import pytest
from vtt_orchestrator.compendium.foundry_importer import FoundryModuleImporter
# --- Fixture builders ---------------------------------------------------------------
def goblin_actor(doc_id="act_goblin"):
"""Realistic dnd5e NPC actor document (one NDJSON line)."""
return {
"_id": doc_id,
"name": "Goblin Snapper",
"type": "npc",
"img": "icons/svg/goblin.svg",
"system": {
"abilities": {
"str": {"value": 15},
"dex": {"value": 14},
"con": {"value": 13},
"int": {"value": 10},
"wis": {"value": 8},
"cha": {"value": 7},
},
"attributes": {
"ac": {"value": 13},
"hp": {"value": 7, "max": 7},
"speed": {"value": "30 ft."},
},
"details": {"cr": "1/4", "type": {"value": "humanoid"}},
},
"items": [
{
"_id": "itm_scimitar",
"name": "Scimitar",
"type": "weapon",
"system": {
"attack": {"bonus": "+4"},
"damage": {"parts": [["1d6+2", "slashing"]]},
},
}
],
"flags": {"core": {"sheetClass": "dnd5e.ActorSheet5eNPC"}},
"ownership": {"default": 0},
}
def potion_item(doc_id="itm_potion"):
return {
"_id": doc_id,
"name": "Potion of Healing",
"type": "consumable",
"system": {
"description": {"value": "<p>Restores 2d4 + 2 hit points.</p>"},
"rarity": "common",
"price": {"value": 50, "denomination": "gp"},
"quantity": 1,
"weight": {"value": 0.5},
},
"flags": {},
}
def crypt_scene(doc_id="scn_crypt"):
return {
"_id": doc_id,
"name": "Karas Crypt Antechamber",
"width": 3200,
"height": 1800,
"grid": 100,
"walls": [
{"_id": "w1", "c": [0, 0, 400, 0], "ls": True, "move": 0, "sight": 0, "door": False},
{"_id": "w2", "c": [400, 0, 800, 0], "ls": True, "move": 1, "sight": 1, "door": True},
],
"background": "modules/demo/crypt.jpg",
"tokens": [],
"navMode": 1,
}
def build_module(
tmp_path,
manifest=None,
packs=None,
write_manifest=True,
subdir="demo-module",
):
"""Create an on-disk Foundry module layout. ``packs`` maps pack filename ->
list of documents (serialized NDJSON) or a raw string body."""
root = tmp_path / subdir
root.mkdir(parents=True, exist_ok=True)
if manifest is None:
manifest = {
"id": "demo-module",
"title": "Demo Module",
"author": "Test Author",
"version": "1.0.0",
"packs": [
{"name": "bestiary", "label": "Bestiary", "path": "packs/bestiary.db", "type": "Actor"},
{"name": "gear", "label": "Gear", "path": "packs/gear.db", "type": "Item"},
{"name": "maps", "label": "Maps", "path": "packs/maps.db", "type": "Scene"},
],
}
if write_manifest:
(root / "module.json").write_text(json.dumps(manifest), encoding="utf-8")
defaults = {
"bestiary.db": [goblin_actor()],
"gear.db": [potion_item()],
"maps.db": [crypt_scene()],
}
for fname, payload in (packs if packs is not None else defaults).items():
pack_path = root / "packs" / fname
pack_path.parent.mkdir(parents=True, exist_ok=True)
if isinstance(payload, str):
body = payload
else:
body = "\n".join(json.dumps(doc) for doc in payload) + "\n"
pack_path.write_text(body, encoding="utf-8")
return root
# --- Happy path ---------------------------------------------------------------------
class TestHappyPath:
def test_imports_monsters_items_and_scene(self, tmp_path):
result = FoundryModuleImporter().import_module(build_module(tmp_path))
assert result["module"]["id"] == "demo-module"
assert result["module"]["title"] == "Demo Module"
assert result["module"]["version"] == "1.0.0"
assert len(result["monsters"]) == 1
gob = result["monsters"][0]
assert gob["name"] == "Goblin Snapper"
assert gob["ac"] == 13
assert gob["hp"] == 7
assert gob["max_hp"] == 7
assert gob["speed"] == 30
assert gob["abilities"] == {
"STR": 15, "DEX": 14, "CON": 13, "INT": 10, "WIS": 8, "CHA": 7,
}
assert len(result["items"]) == 1
pot = result["items"][0]
assert pot["name"] == "Potion of Healing"
assert pot["category"] == "consumable"
assert pot["rarity"] == "common"
assert pot["price_value"] == 50
assert pot["price_denomination"] == "gp"
assert len(result["maps"]) == 1
mp = result["maps"][0]
assert mp["name"] == "Karas Crypt Antechamber"
assert mp["dimensions"]["width"] == 3200
assert mp["dimensions"]["height"] == 1800
assert mp["dimensions"]["grid_size"] == 100
assert len(mp["walls"]) == 2
# Nothing failed silently on a fully mappable module.
assert result["skipped"] == 0
assert result["imported"] == 3
assert result["warnings"] == []
def test_actions_projected_from_embedded_weapon_items(self, tmp_path):
result = FoundryModuleImporter().import_module(build_module(tmp_path))
actions = result["monsters"][0]["actions"]
assert actions == [
{
"name": "Scimitar",
"to_hit": "+4",
"damage_formula": "1d6+2",
"damage_type": "slashing",
}
]
def test_wall_geometry_projects_pixel_coordinates(self, tmp_path):
result = FoundryModuleImporter().import_module(build_module(tmp_path))
w1, w2 = result["maps"][0]["walls"]
assert (w1["x1"], w1["y1"], w1["x2"], w1["y2"]) == (0, 0, 400, 0)
assert w1["door"] is False
assert w2["door"] is True
# --- NDJSON parsing -----------------------------------------------------------------
class TestNdjsonParsing:
def test_multiple_documents_one_per_line(self, tmp_path):
ogre = goblin_actor("act_ogre")
ogre["name"] = "Cave Ogre"
ogre["_id"] = "act_ogre"
ogre["system"]["attributes"]["ac"]["value"] = 17
mod_dir = build_module(tmp_path, packs={"bestiary.db": [goblin_actor(), ogre]})
result = FoundryModuleImporter().import_module(mod_dir)
assert [m["name"] for m in result["monsters"]] == ["Cave Ogre", "Goblin Snapper"]
def test_blank_lines_are_tolerated(self, tmp_path):
body = "\n\n" + json.dumps(goblin_actor()) + "\n\n"
mod_dir = build_module(tmp_path, packs={"bestiary.db": body})
result = FoundryModuleImporter().import_module(mod_dir)
assert len(result["monsters"]) == 1
assert result["imported"] == 1
def test_malformed_line_is_reported_and_skipped(self, tmp_path):
body = (
json.dumps(goblin_actor())
+ "\n{not valid json\n"
+ json.dumps(potion_item())
)
build_module(
tmp_path,
packs={
"bestiary.db": body.splitlines()[0] + "\n{not valid json\n",
"gear.db": json.dumps(potion_item()),
"maps.db": "",
},
)
result = FoundryModuleImporter().import_module(tmp_path / "demo-module")
assert len(result["monsters"]) == 1
assert result["skipped"] >= 1
assert any("bestiary" in w and "line 2" in w for w in result["warnings"])
# --- Unmapped-field accounting ------------------------------------------------------
class TestUnmappedAccounting:
def test_top_level_and_nested_unmapped_fields_are_listed(self, tmp_path):
result = FoundryModuleImporter().import_module(build_module(tmp_path))
gob = result["monsters"][0]
# Top-level fields with no canon projection...
assert "img" in gob["unmapped"]
assert "flags" in gob["unmapped"]
assert "ownership" in gob["unmapped"]
# ...and consumed-container leftovers (details was never mapped).
assert "system.details" in gob["unmapped"]
def test_every_mapped_field_is_absent_from_unmapped(self, tmp_path):
result = FoundryModuleImporter().import_module(build_module(tmp_path))
gob = result["monsters"][0]
for mapped in ("name", "system", "items"):
assert mapped not in gob["unmapped"]
def test_non_combat_embedded_items_are_accounted(self, tmp_path):
actor = goblin_actor()
actor["items"].append(
{"_id": "itm_key", "name": "Rusty Key", "type": "loot", "system": {}}
)
build_module(tmp_path, packs={"bestiary.db": [actor], "gear.db": [], "maps.db": []})
result = FoundryModuleImporter().import_module(tmp_path / "demo-module")
gob = result["monsters"][0]
assert any("Rusty Key" in u for u in gob["unmapped"])
# The weapon still projects normally.
assert [a["name"] for a in gob["actions"]] == ["Scimitar"]
def test_scene_wall_extra_flags_are_recorded(self, tmp_path):
result = FoundryModuleImporter().import_module(build_module(tmp_path))
w1 = result["maps"][0]["walls"][0]
assert "ls" in w1["unmapped"]
assert "move" in w1["unmapped"]
assert "sight" in w1["unmapped"]
# --- Fail-loud contract ---------------------------------------------------------------
class TestFailLoud:
def test_missing_manifest_raises_value_error(self, tmp_path):
build_module(tmp_path, write_manifest=False)
with pytest.raises(ValueError, match="module.json"):
FoundryModuleImporter().import_module(tmp_path / "demo-module")
def test_invalid_manifest_json_raises_value_error(self, tmp_path):
build_module(tmp_path, packs={})
(tmp_path / "demo-module" / "module.json").write_text("{oops", encoding="utf-8")
with pytest.raises(ValueError, match="invalid JSON"):
FoundryModuleImporter().import_module(tmp_path / "demo-module")
@pytest.mark.parametrize("bad_version", [None, "", 42, " "])
def test_invalid_version_raises_value_error(self, tmp_path, bad_version):
manifest = {
"id": "demo-module",
"title": "Demo Module",
"version": bad_version,
"packs": [],
}
build_module(tmp_path, manifest=manifest, packs={})
with pytest.raises(ValueError, match="version"):
FoundryModuleImporter().import_module(tmp_path / "demo-module")
# --- Partial imports / tolerance ------------------------------------------------------
class TestPartialImports:
def test_missing_pack_file_warns_and_counts_skipped(self, tmp_path):
build_module(tmp_path, packs={"bestiary.db": [goblin_actor()]})
result = FoundryModuleImporter().import_module(tmp_path / "demo-module")
assert len(result["monsters"]) == 1
assert result["skipped"] == 2 # gear + maps pack files absent
missing = [w for w in result["warnings"] if "missing pack file" in w]
assert len(missing) == 2
def test_empty_pack_is_tolerated(self, tmp_path):
build_module(tmp_path, packs={"bestiary.db": [], "gear.db": "\n", "maps.db": ""})
result = FoundryModuleImporter().import_module(tmp_path / "demo-module")
assert result["monsters"] == []
assert result["items"] == []
assert result["maps"] == []
assert result["imported"] == 0
assert result["skipped"] == 0
assert result["warnings"] == []
def test_unsupported_pack_type_is_skipped_with_warning(self, tmp_path):
manifest = {
"id": "demo-module",
"title": "Demo Module",
"version": "1.0.0",
"packs": [
{"name": "lore", "label": "Lore", "path": "packs/lore.db", "type": "JournalEntry"},
{"name": "bestiary", "label": "B", "path": "packs/bestiary.db", "type": "Actor"},
],
}
build_module(tmp_path, manifest=manifest, packs={"bestiary.db": [goblin_actor()]})
result = FoundryModuleImporter().import_module(tmp_path / "demo-module")
assert len(result["monsters"]) == 1
assert result["skipped"] == 1
assert any("JournalEntry" in w for w in result["warnings"])
def test_level_db_directory_packs_are_skipped_with_warning(self, tmp_path):
manifest = {
"id": "demo-module",
"title": "Demo Module",
"version": "1.0.0",
"packs": [
{"name": "modern", "label": "M", "path": "packs/modern", "type": "Actor"},
],
}
build_module(tmp_path, manifest=manifest, packs={})
(tmp_path / "demo-module" / "packs" / "modern").mkdir(parents=True)
result = FoundryModuleImporter().import_module(tmp_path / "demo-module")
assert result["imported"] == 0
assert result["skipped"] == 1
assert any("LevelDB" in w for w in result["warnings"])
def test_manifest_pack_path_escape_is_skipped_with_warning(self, tmp_path):
"""F-A3#2: a manifest-controlled ``path`` that escapes the module dir
("../ydata/room.ydoc") must never be read from outside the tree — the
pack is skipped with a warning and the remaining packs still import."""
manifest = {
"id": "demo-module",
"title": "Demo Module",
"version": "1.0.0",
"packs": [
{"name": "escape", "label": "E",
"path": "../ydata/room.ydoc", "type": "Actor"},
{"name": "bestiary", "label": "B",
"path": "packs/bestiary.db", "type": "Actor"},
],
}
build_module(tmp_path, manifest=manifest,
packs={"bestiary.db": [goblin_actor()]})
# The file the escape would reach exists OUTSIDE the module dir —
# reading it would already be a containment failure.
ydata = tmp_path / "ydata"
ydata.mkdir(parents=True, exist_ok=True)
(ydata / "room.ydoc").write_text(
"\n".join(json.dumps(goblin_actor("act_outside"))) + "\n",
encoding="utf-8",
)
result = FoundryModuleImporter().import_module(tmp_path / "demo-module")
assert result["imported"] == 1
assert [m["source_id"] for m in result["monsters"]] == ["act_goblin"]
assert result["skipped"] == 1
escape_warnings = [
w for w in result["warnings"]
if "escape" in w and ("escapes" in w or "outside" in w)
]
assert len(escape_warnings) == 1
def test_manifest_absolute_pack_path_is_skipped_with_warning(self, tmp_path):
"""An absolute ``path`` is equally out-of-bounds for a module dir."""
manifest = {
"id": "demo-module",
"title": "Demo Module",
"version": "1.0.0",
"packs": [
{"name": "steal", "label": "S",
"path": "/etc/passwd", "type": "Item"},
{"name": "gear", "label": "G",
"path": "packs/gear.db", "type": "Item"},
],
}
build_module(tmp_path, manifest=manifest, packs={"gear.db": [potion_item()]})
result = FoundryModuleImporter().import_module(tmp_path / "demo-module")
assert result["imported"] == 1
assert [i["source_id"] for i in result["items"]] == ["itm_potion"]
assert result["skipped"] == 1
assert any("/etc/passwd" in w for w in result["warnings"])
def test_manifest_deeply_nested_but_contained_path_is_allowed(self, tmp_path):
"""Containment is about staying INSIDE the module dir, not path depth:
a legitimate nested pack path keeps importing."""
manifest = {
"id": "demo-module",
"title": "Demo Module",
"version": "1.0.0",
"packs": [
{"name": "nested", "label": "N",
"path": "packs/nested/deep/bestiary.db", "type": "Actor"},
],
}
build_module(tmp_path, manifest=manifest)
deep = tmp_path / "demo-module" / "packs" / "nested" / "deep"
deep.mkdir(parents=True, exist_ok=True)
(deep / "bestiary.db").write_text(
json.dumps(goblin_actor()) + "\n", encoding="utf-8"
)
result = FoundryModuleImporter().import_module(tmp_path / "demo-module")
assert len(result["monsters"]) == 1
assert result["skipped"] == 0
assert result["warnings"] == []
def test_document_without_name_is_skipped_with_warning(self, tmp_path):
anon = goblin_actor()
anon["name"] = ""
build_module(tmp_path, packs={"bestiary.db": [anon], "gear.db": [], "maps.db": []})
result = FoundryModuleImporter().import_module(tmp_path / "demo-module")
assert result["monsters"] == []
assert result["skipped"] == 1
assert any("unnamed" in w.lower() for w in result["warnings"])
# --- Determinism -----------------------------------------------------------------------
class TestDeterministicOutput:
def test_entries_sorted_by_name_then_id(self, tmp_path):
b_doc = goblin_actor("act_b")
b_doc["name"] = "Zombie Brute"
a_doc = goblin_actor("act_a")
a_doc["name"] = "Acolyte"
dup1 = goblin_actor("act_dup1")
dup1["name"] = "Acolyte"
dup2 = goblin_actor("act_dup2")
dup2["name"] = "Acolyte"
build_module(
tmp_path,
packs={
"bestiary.db": [dup2, b_doc, dup1, a_doc],
"gear.db": [],
"maps.db": [],
},
)
result = FoundryModuleImporter().import_module(tmp_path / "demo-module")
assert [(m["name"], m["source_id"]) for m in result["monsters"]] == [
("Acolyte", "act_a"),
("Acolyte", "act_dup1"),
("Acolyte", "act_dup2"),
("Zombie Brute", "act_b"),
]
def test_repeated_import_yields_identical_payload(self, tmp_path):
mod_dir = build_module(tmp_path)
first = FoundryModuleImporter().import_module(mod_dir)
second = FoundryModuleImporter().import_module(mod_dir)
assert json.dumps(first, sort_keys=True) == json.dumps(second, sort_keys=True)
def test_unmapped_lists_are_sorted(self, tmp_path):
result = FoundryModuleImporter().import_module(build_module(tmp_path))
unmapped = result["monsters"][0]["unmapped"]
assert unmapped == sorted(unmapped)
# --- Out-of-scope guard ----------------------------------------------------------------
def test_roll20_export_parsing_is_out_of_scope():
import vtt_orchestrator.compendium.foundry_importer as fi
assert not hasattr(fi.FoundryModuleImporter, "import_roll20_export")
assert getattr(fi, "ROLL20_IN_SCOPE", True) is False