feat: add mod editing, revision merges and secure game storage
Add immutable revisions, PGVN editor, asset replacement, merge and rebase workflows, authenticated media delivery and reachability GC. Harden the script interpreter, pin unrpyc and remove tracked build metadata. Include current compatibility checks, Naninovel import and regression tests.
This commit is contained in:
1 parent
06ff19ac4a
commit
c8d83b3fe7
65 files changed
+3825
-68
No files matched your search
+2
-2
@@ -18,8 +18,8 @@ transform slightleft:
|
||||
xalign 0.3 yalign 1.0
|
||||
|
||||
init python:
|
||||
def helper():
|
||||
return 1
|
||||
|
||||
|
||||
bonus = 5
|
||||
persistent.seen_intro = False
|
||||
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
"""Disposable local UI fixture. Run seed/cleanup only on the pgames-test stand."""
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy import delete
|
||||
|
||||
from app.config import get_settings
|
||||
from app.converter.pipeline import convert_archive
|
||||
from app.db import SessionLocal
|
||||
from app.maintenance import storage_access
|
||||
from app.models import Game, GameVersion, Role, User
|
||||
from app.security import hash_password
|
||||
from tests.make_sample import build
|
||||
|
||||
|
||||
async def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("action", choices=("seed", "check", "cleanup"))
|
||||
args = parser.parse_args()
|
||||
record = get_settings().data_dir / "mod-ui-fixture.json"
|
||||
if args.action == "check":
|
||||
import http.cookiejar
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
data = json.loads(record.read_text())
|
||||
host = "http://web"
|
||||
opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(http.cookiejar.CookieJar()))
|
||||
payload = json.dumps({"username": data["username"], "password": data["password"]}).encode()
|
||||
opener.open(urllib.request.Request(host + "/api/auth/login", data=payload, headers={"Content-Type": "application/json"}))
|
||||
manifest = json.load(opener.open(host + f'/api/versions/{data["version_id"]}/manifest'))
|
||||
key = manifest["files"]["game.json"]["hash"]
|
||||
file_url = host + f'/api/versions/{data["version_id"]}/files/{key}'
|
||||
response = opener.open(file_url)
|
||||
assert response.status == 200 and "private" in response.headers["Cache-Control"]
|
||||
assert json.load(response)["format"] == "pgvn/1"
|
||||
assert opener.open(urllib.request.Request(file_url, method="HEAD")).status == 200
|
||||
audio = next(f["hash"] for p, f in manifest["files"].items() if p.endswith(".ogg"))
|
||||
response = opener.open(urllib.request.Request(host + f'/api/versions/{data["version_id"]}/files/{audio}', headers={"Range": "bytes=0-9"}))
|
||||
assert response.status == 206 and len(response.read()) == 10
|
||||
for url, status in [(file_url, 401), (host + f"/_protected_blobs/{key[:2]}/{key[2:4]}/{key}", 404)]:
|
||||
try:
|
||||
urllib.request.urlopen(url)
|
||||
except urllib.error.HTTPError as error:
|
||||
assert error.code == status
|
||||
else:
|
||||
raise AssertionError("Unprotected media route")
|
||||
print("nginx: authenticated JSON/HEAD/Range OK; anonymous and direct internal access denied")
|
||||
return
|
||||
async with storage_access():
|
||||
async with SessionLocal() as db:
|
||||
if args.action == "cleanup":
|
||||
if not record.exists():
|
||||
return
|
||||
data = json.loads(record.read_text())
|
||||
user = await db.get(User, data["user_id"])
|
||||
if not user or user.username != data["username"] or not user.username.startswith("mod-ui-"):
|
||||
raise RuntimeError("Fixture identity mismatch; cleanup aborted")
|
||||
await db.execute(delete(Game).where(Game.created_by_id == user.id))
|
||||
await db.delete(user)
|
||||
await db.commit()
|
||||
await db.execute(delete(Role).where(Role.id == data["role_id"]))
|
||||
await db.commit()
|
||||
record.unlink()
|
||||
print("Disposable UI user and games removed")
|
||||
return
|
||||
if record.exists():
|
||||
print(record.read_text())
|
||||
return
|
||||
suffix = uuid4().hex[:10]
|
||||
password = uuid4().hex
|
||||
role = Role(name="mod-ui-" + suffix, permissions=["games.play", "versions.fork", "games.upload", "games.edit", "versions.publish"])
|
||||
db.add(role)
|
||||
await db.flush()
|
||||
user = User(username="mod-ui-" + suffix, password_hash=hash_password(password), role_id=role.id, must_change_password=False)
|
||||
db.add(user)
|
||||
await db.flush()
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
root = Path(folder)
|
||||
result = await asyncio.to_thread(convert_archive, build(root / "sample.zip"), "sample.zip", root / "work", lambda _: None)
|
||||
game = Game(title="Проверка редактора модов", created_by_id=user.id)
|
||||
db.add(game)
|
||||
await db.flush()
|
||||
original = GameVersion(game_id=game.id, owner_id=user.id, name="Оригинал для проверки", status="ready", manifest=result.manifest)
|
||||
db.add(original)
|
||||
await db.flush()
|
||||
fork = GameVersion(game_id=game.id, parent_id=original.id, owner_id=user.id, name="Мой тестовый мод", kind="mod", visibility="draft", status="ready", manifest=result.manifest, base_revision_id=original.revision_id)
|
||||
fork._revision_parents = [original.revision_id]
|
||||
db.add(fork)
|
||||
await db.commit()
|
||||
data = dict(username=user.username, password=password, user_id=user.id, role_id=role.id, version_id=fork.id, game_id=game.id)
|
||||
record.write_text(json.dumps(data))
|
||||
print(json.dumps(data))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,279 @@
|
||||
import asyncio
|
||||
import json
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
import zipfile
|
||||
|
||||
import pytest
|
||||
|
||||
from app.config import get_settings
|
||||
from app.converter.compatibility import CompatibilityError, audit_ir, detect, require_supported_scripts
|
||||
from app.converter.nani_compiler import compile_package
|
||||
from app.converter.nani_expr import assignment, parse
|
||||
from app.converter.nani_package import Package, decode_script, text_records
|
||||
from app.converter.pipeline import convert_archive
|
||||
from app.converter.preflight import inspect_directory, probe_archive
|
||||
from app.converter.rpy_parser import RpyParser, ScriptData
|
||||
from app.import_library import check, entries
|
||||
from app.translation import LineInfo, validate_nani_translation
|
||||
|
||||
|
||||
def write(path, text=""):
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(text, encoding="utf-8")
|
||||
return path
|
||||
|
||||
|
||||
def script(source):
|
||||
data = ScriptData()
|
||||
RpyParser(data, "script.rpy").parse(source)
|
||||
return data
|
||||
|
||||
|
||||
def package(*commands):
|
||||
return Package(scripts={"Opening": {"name": "Opening", "lines": [
|
||||
{"line": i, "commands": [command]} for i, command in enumerate(commands)]}})
|
||||
|
||||
|
||||
def command(op, **args):
|
||||
return {"op": op, "args": args}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("engine,marker", [
|
||||
("unity", "Game_Data/Managed/Assembly-CSharp.dll"),
|
||||
("unity", "Game_Data/il2cpp_data/Metadata/global-metadata.dat"),
|
||||
("gamemaker", "data.win"), ("godot", "Game.pck"), ("rpgmaker", "Game.rgss3a")])
|
||||
def test_unsupported_engines_fail_with_reason(tmp_path, engine, marker):
|
||||
write(tmp_path / marker)
|
||||
with pytest.raises(CompatibilityError) as exc:
|
||||
detect(tmp_path)
|
||||
assert exc.value.engine == engine
|
||||
assert exc.value.report()["issues"][0]["code"] == "unsupported_engine"
|
||||
|
||||
|
||||
def test_naninovel_detection_ignores_migration_copies(tmp_path):
|
||||
write(tmp_path / "Game_Data/Managed/Elringus.Naninovel.Runtime.dll")
|
||||
write(tmp_path / "migration-staging/port/project.godot")
|
||||
assert detect(tmp_path).engine == "naninovel"
|
||||
|
||||
|
||||
def test_multiple_same_engine_games_are_not_merged(tmp_path):
|
||||
write(tmp_path / "one/game/script.rpy", 'label start:\n "one"')
|
||||
write(tmp_path / "two/game/script.rpy", 'label start:\n "two"')
|
||||
with pytest.raises(CompatibilityError, match="несколько игр"):
|
||||
detect(tmp_path)
|
||||
|
||||
|
||||
def test_renpy_only_declarations_are_not_execution_blockers(tmp_path):
|
||||
source = 'screen unused():\n text "UI"\nlabel start:\n "Hello"\n return\n'
|
||||
write(tmp_path / "game/script.rpy", source)
|
||||
assert inspect_directory(tmp_path)["compatible"]
|
||||
write(tmp_path / "game/script.rpy", source.replace(' "Hello"', ' call screen unused'))
|
||||
with pytest.raises(CompatibilityError, match="call screen"):
|
||||
inspect_directory(tmp_path)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("source,reason", [
|
||||
('init python:\n def helper():\n pass\nlabel start:\n "Hi"', "init python"),
|
||||
('label start:\n $ result = renpy.show_screen("map")', "renpy.show_screen"),
|
||||
('label start:\n jump missing', "missing"),
|
||||
])
|
||||
def test_unsupported_renpy_logic_never_degrades_to_ready(source, reason):
|
||||
data = script(source)
|
||||
audit_ir(data)
|
||||
with pytest.raises(CompatibilityError, match=reason):
|
||||
require_supported_scripts(data)
|
||||
|
||||
|
||||
def test_directory_rejection_precedes_copying_media(tmp_path, monkeypatch):
|
||||
write(tmp_path / "source/game/script.rpy", 'label start:\n call screen map')
|
||||
def media(*args, **kwargs):
|
||||
pytest.fail("Media must not be copied/converted for an unsupported game")
|
||||
monkeypatch.setattr("app.converter.pipeline.shutil.copytree", media)
|
||||
monkeypatch.setattr("app.converter.pipeline.assets.convert", media)
|
||||
with pytest.raises(CompatibilityError):
|
||||
convert_archive(tmp_path / "source", "source", tmp_path / "work", lambda _: None)
|
||||
assert (tmp_path / "source/game/script.rpy").exists()
|
||||
|
||||
|
||||
def test_archive_probe_does_not_extract_anything(tmp_path):
|
||||
source = tmp_path / "game.zip"
|
||||
with zipfile.ZipFile(source, "w") as archive:
|
||||
archive.writestr("Game/data.win", b"placeholder")
|
||||
with pytest.raises(CompatibilityError) as exc:
|
||||
probe_archive(source, source.name)
|
||||
assert exc.value.engine == "gamemaker"
|
||||
assert list(tmp_path.iterdir()) == [source]
|
||||
|
||||
|
||||
def test_archive_probe_ignores_engine_runtime_scripts(tmp_path):
|
||||
source = tmp_path / "game.zip"
|
||||
with zipfile.ZipFile(source, "w") as archive:
|
||||
archive.writestr("Game/Game_Data/Managed/Elringus.Naninovel.Runtime.dll", b"")
|
||||
archive.writestr("Game/renpy/common/script.rpy", b"")
|
||||
assert probe_archive(source, source.name)["engine"] == "naninovel"
|
||||
|
||||
|
||||
def test_script_preflight_does_not_unpack_loose_media(tmp_path):
|
||||
from app.converter.archive import extract
|
||||
source = tmp_path / "game.zip"
|
||||
with zipfile.ZipFile(source, "w") as archive:
|
||||
archive.writestr("Game/game/script.rpy", 'label start:\n call screen map')
|
||||
archive.writestr("Game/game/images/large.png", b"media")
|
||||
destination = tmp_path / "scripts"
|
||||
extract(source, source.name, destination, only_scripts=True)
|
||||
assert (destination / "Game/game/script.rpy").exists()
|
||||
assert not (destination / "Game/game/images").exists()
|
||||
|
||||
|
||||
def test_batch_prefers_unpacked_duplicate_and_reports_executables(tmp_path):
|
||||
(tmp_path / "Game").mkdir()
|
||||
write(tmp_path / "Game.zip")
|
||||
write(tmp_path / "Other.exe")
|
||||
assert [p.name for p in entries(tmp_path)] == ["Game", "Other.exe"]
|
||||
assert check(tmp_path / "Other.exe")["issues"][0]["code"] == "not_distribution"
|
||||
|
||||
|
||||
def test_naninovel_decodes_line_order_and_dynamic_parameters():
|
||||
tree = {"m_Name": "Opening", "lines": [{"id": 1}], "references": {"records": [
|
||||
{"type": {"class": "PrintText"}, "data": {
|
||||
"Text": {"hasValue": False, "value": "", "dynamicValue": {"ValueText": "Score: {score}"}},
|
||||
"playbackSpot": {"lineIndex": 3}}},
|
||||
{"type": {"class": "GenericTextScriptLine"}, "data": {
|
||||
"lineIndex": 3, "lineHash": "original", "inlinedCommands": [{"id": 0}]}}]}}
|
||||
decoded = decode_script(tree)
|
||||
assert decoded["lines"][0]["commands"][0]["args"]["Text"] == {"template": "Score: {score}"}
|
||||
tree["lines"][0]["id"] = -1
|
||||
with pytest.raises(ValueError, match="ссылка"):
|
||||
decode_script(tree)
|
||||
|
||||
|
||||
def test_naninovel_compiles_dialogue_branches_choices_and_expressions():
|
||||
original = package(command("SetCustomVariable", Expression="score=2"),
|
||||
command("BeginIf", Expression="score>=1 && true"),
|
||||
command("PrintText", Text={"template": "Score: {score}"}, AuthorId="Alice"),
|
||||
command("Else"), command("PrintText", Text="Low"), command("EndIf"),
|
||||
command("AddChoice", ChoiceSummary="Again", GotoPath="Opening"), command("Stop"))
|
||||
data, entry, expressions = compile_package(original)
|
||||
body = data.modules["Opening.nani"][0]["body"]
|
||||
assert entry == "nani:Opening"
|
||||
assert body[0]["code"][0]["v"]["k"] == "nani"
|
||||
assert len(body[1]["branches"]) == 2
|
||||
assert body[2]["op"] == "nani_add_choice" and body[3]["op"] == "nani_menu"
|
||||
assert expressions == {"score": ["name", "score"]}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("commands,reason", [
|
||||
([command("Spawn", Path="Minigame")], "Spawn"),
|
||||
([command("PrintText", Text="Text", Unexpected=True)], "Unexpected"),
|
||||
([command("SetCustomVariable", Expression="x=CustomFunction()")], "CustomFunction"),
|
||||
([command("SetCustomVariable", Expression={"template": "x={other}"})], "Динамический"),
|
||||
([command("Stop")], "внешнего интерфейса"),
|
||||
([command("Goto", Path="Missing")], "цель"),
|
||||
([command("BeginIf", Expression="true")], "Не закрыт"),
|
||||
])
|
||||
def test_naninovel_unknown_commands_functions_and_fields_are_fatal(commands, reason):
|
||||
with pytest.raises(CompatibilityError, match=reason):
|
||||
compile_package(package(*commands))
|
||||
|
||||
|
||||
def test_naninovel_expression_parser_never_accepts_code():
|
||||
ast = parse('score>1 ? "Yes" : "No"')
|
||||
assert ast[0] == "if"
|
||||
assert parse('"Привет\\n"')[1] == "Привет\n"
|
||||
assert assignment("score=score+1")["v"]["k"] == "nani"
|
||||
for source in ('__import__("os")', 'x.constructor()', 'x[0]', 'x; other()', 'x ? 1'):
|
||||
with pytest.raises(ValueError):
|
||||
parse(source)
|
||||
|
||||
|
||||
def test_naninovel_resources_keep_keys_delimiters_and_context():
|
||||
records = text_records("Tips", "# comment\nHeading\nHelp:Title|Description\n", "Tips")
|
||||
assert [r["text"] for r in records.values()] == ["Title", "Description"]
|
||||
assert len(text_records("_synonyms", "word=hello,hi\n", "synonyms")) == 2
|
||||
with pytest.raises(ValueError, match="Повторяющийся"):
|
||||
text_records("DefaultUI", "Key:A\nKey:B", "UI")
|
||||
info = LineInfo("id", "node", "script/resources.json", "resources", "", "<b>Hello {name}</b>", None,
|
||||
resource={"delimiters": "|\r\n"})
|
||||
validate_nani_translation(info, "<b>Привет {name}</b>")
|
||||
for invalid in ("Привет {name}", "<b>Привет {other}</b>", "<b>Привет {name}|test</b>"):
|
||||
with pytest.raises(ValueError):
|
||||
validate_nani_translation(info, invalid)
|
||||
|
||||
|
||||
def test_naninovel_import_localization_and_mod_editor_roundtrip(tmp_path, monkeypatch):
|
||||
from app.blobs import read_bytes
|
||||
from app.converter.naninovel import convert_naninovel
|
||||
from app.translation import apply_changes, locale_tables, text_index
|
||||
from app.mods import document, validate
|
||||
|
||||
monkeypatch.setenv("DATA_DIR", str(tmp_path / "library"))
|
||||
get_settings.cache_clear()
|
||||
original = package(command("SetCustomVariable", Expression="score=3"),
|
||||
command("PrintText", Text="Hello {score}"),
|
||||
command("AddChoice", ChoiceSummary="Again", GotoPath="Opening"), command("Stop"))
|
||||
original.texts = text_records("Tips", "Help:Title|Description\n", "Tips")
|
||||
monkeypatch.setattr("app.converter.naninovel.read_package", lambda *args, **kwargs: original)
|
||||
try:
|
||||
result = convert_naninovel(tmp_path, tmp_path / "work", lambda _: None)
|
||||
game = json.loads(read_bytes(result.manifest["files"]["game.json"]["hash"]))
|
||||
assert game["engine"] == "naninovel" and game["entry"] == "nani:Opening"
|
||||
index = text_index(result.manifest)
|
||||
assert len(index.lines) == 4 # dialogue, choice, two independently addressed Tips parts
|
||||
line = next(info for info in index.lines if info.source == "Hello {score}")
|
||||
translated = apply_changes(result.manifest, "ru", lines={line.tid: {"t": "Привет {score}"}})
|
||||
assert locale_tables(translated, "ru")[0][line.tid]["t"] == "Привет {score}"
|
||||
with pytest.raises(ValueError, match="выражения"):
|
||||
apply_changes(result.manifest, "ru", lines={line.tid: {"t": "Привет"}})
|
||||
validate(document(translated), translated)
|
||||
finally:
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
def test_unity_codec_restores_global_hooks_after_error():
|
||||
from UnityPy.helpers import TypeTreeHelper as helper
|
||||
from app.converter.unity_scripts import ScriptCodec
|
||||
codec = ScriptCodec.__new__(ScriptCodec)
|
||||
before = (helper.read_value, helper.write_value, helper.read_typetree_boost, helper.get_ref_type_node)
|
||||
with pytest.raises(RuntimeError):
|
||||
with codec.active():
|
||||
raise RuntimeError("broken object")
|
||||
assert (helper.read_value, helper.write_value, helper.read_typetree_boost, helper.get_ref_type_node) == before
|
||||
|
||||
|
||||
def test_upload_rejects_unsupported_engine_without_queueing(tmp_path, monkeypatch):
|
||||
from app.routers import versions
|
||||
from app.uploads import upload_path
|
||||
from fastapi import HTTPException
|
||||
|
||||
source = tmp_path / "unsupported.zip"
|
||||
with zipfile.ZipFile(source, "w") as archive:
|
||||
archive.writestr("data.win", b"game")
|
||||
monkeypatch.setenv("DATA_DIR", str(tmp_path / "library"))
|
||||
get_settings.cache_clear()
|
||||
states = []
|
||||
|
||||
class DB:
|
||||
version = None
|
||||
def add(self, version):
|
||||
version.id = 123
|
||||
self.version = version
|
||||
async def commit(self):
|
||||
states.append(self.version.status)
|
||||
async def delete(self, version):
|
||||
assert version.status != "queued"
|
||||
|
||||
async def game(*_):
|
||||
return SimpleNamespace(id=1)
|
||||
async def stream():
|
||||
yield source.read_bytes()
|
||||
monkeypatch.setattr(versions, "get_game", game)
|
||||
try:
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
asyncio.run(versions.upload_version(1, SimpleNamespace(stream=stream), SimpleNamespace(id=1), DB(), source.name))
|
||||
assert exc.value.status_code == 422
|
||||
assert "gamemaker" in exc.value.detail
|
||||
assert "queued" not in states
|
||||
assert not upload_path(123).exists()
|
||||
finally:
|
||||
get_settings.cache_clear()
|
||||
@@ -0,0 +1,27 @@
|
||||
import asyncio
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
from app.config import get_settings
|
||||
from app.maintenance import acquire, release
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.name == "nt", reason="Production shared flock semantics are POSIX")
|
||||
def test_gc_excludes_active_writers(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("DATA_DIR", str(tmp_path))
|
||||
get_settings.cache_clear()
|
||||
async def check():
|
||||
writer = acquire()
|
||||
gc = asyncio.create_task(asyncio.to_thread(acquire, True))
|
||||
try:
|
||||
with pytest.raises(asyncio.TimeoutError):
|
||||
await asyncio.wait_for(asyncio.shield(gc), .1)
|
||||
finally:
|
||||
release(writer)
|
||||
maintenance = await asyncio.wait_for(gc, 3)
|
||||
release(maintenance)
|
||||
try:
|
||||
asyncio.run(check())
|
||||
finally:
|
||||
get_settings.cache_clear()
|
||||
@@ -0,0 +1,163 @@
|
||||
"""Integration against PostgreSQL in a temporary schema, never touches library rows."""
|
||||
import asyncio
|
||||
import copy
|
||||
import os
|
||||
from uuid import uuid4
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from sqlalchemy import select, text
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
|
||||
from app import db as database
|
||||
from app.blobs import put_bytes
|
||||
from app.db import Base
|
||||
from app.main import app
|
||||
from app.models import Game, GameVersion, Revision, Role, User
|
||||
from app.permissions import ALL
|
||||
from app.security import COOKIE_NAME, create_token
|
||||
from tests.test_mods import pg_manifest # noqa: F401
|
||||
|
||||
|
||||
@pytest.mark.skipif(not os.getenv("TEST_POSTGRES"), reason="Set TEST_POSTGRES=1 to use isolated PostgreSQL schema")
|
||||
def test_complete_mod_workflow(pg_manifest, monkeypatch):
|
||||
async def scenario():
|
||||
schema = "test_mods_" + uuid4().hex
|
||||
engine = create_async_engine(database.engine.url, connect_args={"server_settings": {"search_path": schema}})
|
||||
factory = async_sessionmaker(engine, expire_on_commit=False)
|
||||
monkeypatch.setattr(database, "SessionLocal", factory)
|
||||
async with engine.begin() as conn:
|
||||
await conn.execute(text(f'CREATE SCHEMA "{schema}"'))
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
try:
|
||||
async with factory() as db:
|
||||
admin = Role(name="admin", permissions=ALL)
|
||||
reader = Role(name="reader", permissions=["games.play"])
|
||||
db.add_all([admin, reader])
|
||||
await db.flush()
|
||||
user = User(username="author", password_hash="unused", role_id=admin.id, must_change_password=False)
|
||||
player = User(username="player", password_hash="unused", role_id=reader.id, must_change_password=False)
|
||||
db.add_all([user, player])
|
||||
await db.flush()
|
||||
game = Game(title="Integration", created_by_id=user.id)
|
||||
db.add(game)
|
||||
await db.flush()
|
||||
original = GameVersion(game_id=game.id, owner_id=user.id, name="Original", status="ready", manifest=pg_manifest)
|
||||
db.add(original)
|
||||
await db.commit()
|
||||
original_id, user_id, player_id = original.id, user.id, player.id
|
||||
original_revision = original.revision_id
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
hash = next(iter(pg_manifest["files"].values()))["hash"]
|
||||
assert (await client.get(f"/blobs/{hash}")).status_code == 401
|
||||
client.cookies.set(COOKIE_NAME, create_token(user_id, 0))
|
||||
assert (await client.get(f"/blobs/{hash}")).status_code == 200
|
||||
async def fork(name):
|
||||
response = await client.post(f"/api/versions/{original_id}/fork", json={"name": name})
|
||||
assert response.status_code == 201, response.text
|
||||
return response.json()
|
||||
a, b = await fork("A"), await fork("B")
|
||||
assert a["base_revision_id"] == original_revision
|
||||
edit = (await client.get(f'/api/versions/{a["id"]}/editor')).json()
|
||||
doc = edit["document"]
|
||||
script_path = next(p for p, m in doc["scripts"].items() if m["labels"])
|
||||
first = doc["scripts"][script_path]["labels"][0]["body"]
|
||||
first.insert(0, {"id": str(uuid4()), "op": "say", "tid": "integration_insert", "what": "Mod insertion"})
|
||||
response = await client.put(f'/api/versions/{a["id"]}/editor', json={"revision": edit["revision"], "document": doc})
|
||||
assert response.status_code == 200, response.text
|
||||
a_revision = response.json()["revision"]
|
||||
assert a_revision != edit["revision"]
|
||||
assert (await client.put(f'/api/versions/{a["id"]}/editor', json={"revision": edit["revision"], "document": doc})).status_code == 409
|
||||
# Draft-only media is unavailable to another player, even with the exact hash.
|
||||
manifest = (await client.get(f'/api/versions/{a["id"]}/manifest')).json()
|
||||
draft_hash = manifest["files"][script_path]["hash"]
|
||||
client.cookies.set(COOKIE_NAME, create_token(player_id, 0))
|
||||
assert (await client.get(f"/blobs/{draft_hash}")).status_code == 404
|
||||
assert (await client.get(f'/api/versions/{a["id"]}/files/{draft_hash}')).status_code == 404
|
||||
client.cookies.set(COOKIE_NAME, create_token(user_id, 0))
|
||||
response = await client.get(f'/api/versions/{a["id"]}/files/{draft_hash}')
|
||||
assert response.status_code == 200 and "private" in response.headers["cache-control"]
|
||||
response = await client.get(f'/api/versions/{a["id"]}/files/{draft_hash}', headers={"If-None-Match": response.headers["etag"]})
|
||||
assert response.status_code == 304
|
||||
body = {"target_id": b["id"], "source_revision": a_revision, "target_revision": b["revision_id"]}
|
||||
preview = await client.post(f'/api/versions/{a["id"]}/merge', json=body)
|
||||
assert preview.status_code == 200 and not preview.json()["conflicts"], preview.text
|
||||
result = await client.post(f'/api/versions/{a["id"]}/merge', json={**body, "apply": True})
|
||||
assert result.status_code == 200, result.text
|
||||
result_id = result.json()["id"]
|
||||
revisions = (await client.get(f'/api/versions/{result_id}/revisions')).json()
|
||||
assert {a_revision, b["revision_id"]} <= {r["id"] for r in revisions}
|
||||
# A new independently imported original has unrelated IDs.
|
||||
from app import mods
|
||||
from app.converter.locales import walk
|
||||
upstream = mods.prepare(mods.document(pg_manifest))
|
||||
for module in upstream["scripts"].values():
|
||||
for label in module["labels"]:
|
||||
label["id"] = str(uuid4())
|
||||
for n in walk(label["body"]):
|
||||
n["id"] = str(uuid4())
|
||||
for child in n.get("branches", []) + n.get("items", []):
|
||||
child["id"] = str(uuid4())
|
||||
upstream["scripts"][script_path]["labels"][0]["body"].append(
|
||||
{"id": str(uuid4()), "op": "say", "tid": "upstream_new", "what": "Upstream addition"})
|
||||
new_manifest = mods.store(upstream, pg_manifest)
|
||||
async with factory() as db:
|
||||
new_original = GameVersion(game_id=game.id, owner_id=user_id, name="New original", status="ready", manifest=new_manifest)
|
||||
db.add(new_original)
|
||||
await db.commit()
|
||||
rebase_body = {"target_id": new_original.id, "source_revision": a_revision,
|
||||
"target_revision": new_original.revision_id, "mode": "rebase"}
|
||||
response = await client.post(f'/api/versions/{a["id"]}/merge', json=rebase_body)
|
||||
assert response.status_code == 200 and not response.json()["conflicts"], response.text
|
||||
response = await client.post(f'/api/versions/{a["id"]}/merge', json={**rebase_body, "apply": True})
|
||||
assert response.status_code == 200, response.text
|
||||
rebased = (await client.get(f'/api/versions/{response.json()["id"]}/editor')).json()
|
||||
rebased_id = response.json()["id"]
|
||||
lines = [n.get("what") for m in rebased["document"]["scripts"].values() for l in m["labels"] for n in walk(l["body"])]
|
||||
assert "Mod insertion" in lines and "Upstream addition" in lines
|
||||
# Repeating the transfer must use the target's canonical identity,
|
||||
# rather than interpreting every original node as an inserted mod node.
|
||||
response = await client.post(f'/api/versions/{rebased_id}/merge', json={
|
||||
"target_id": new_original.id, "source_revision": rebased["revision"],
|
||||
"target_revision": new_original.revision_id, "mode": "rebase", "apply": True})
|
||||
assert response.status_code == 200, response.text
|
||||
repeated = (await client.get(f'/api/versions/{response.json()["id"]}/editor')).json()
|
||||
repeated_lines = [n.get("what") for m in repeated["document"]["scripts"].values() for l in m["labels"] for n in walk(l["body"])]
|
||||
assert repeated_lines.count("Mod insertion") == 1
|
||||
assert repeated_lines.count("Upstream addition") == 1
|
||||
# A real replacement is converted, and a stale upload is refused.
|
||||
import io
|
||||
from PIL import Image
|
||||
image = io.BytesIO()
|
||||
Image.new("RGB", (32, 32), "red").save(image, "PNG")
|
||||
asset_path = next(p for p in pg_manifest["files"] if p.endswith(".webp"))
|
||||
response = await client.put(f'/api/versions/{a["id"]}/asset',
|
||||
params={"path": asset_path, "revision": a_revision, "filename": "new.png"}, content=image.getvalue())
|
||||
assert response.status_code == 200, response.text
|
||||
replaced = response.json()
|
||||
assert replaced["files"][asset_path]["hash"] != pg_manifest["files"][asset_path]["hash"]
|
||||
a_revision = replaced["revision"]
|
||||
# Base survives deletion of the parent version card.
|
||||
assert (await client.delete(f"/api/versions/{original_id}")).status_code == 204
|
||||
async with factory() as db:
|
||||
assert await db.get(Revision, original_revision)
|
||||
# Restore keeps a new revision and the old revision is still reachable.
|
||||
response = await client.post(f'/api/versions/{a["id"]}/revisions/restore', json={"revision": a_revision, "restore": edit["revision"]})
|
||||
assert response.status_code == 200, response.text
|
||||
# GC preserves current/base/history; collects a genuinely unreferenced blob.
|
||||
from app import gc
|
||||
monkeypatch.setattr(gc, "SessionLocal", factory)
|
||||
garbage_hash, _ = put_bytes(b"unreferenced-integration", ".bin")
|
||||
report = await gc.collect(False)
|
||||
assert report["files"] >= 1
|
||||
from app.blobs import blob_path
|
||||
assert blob_path(garbage_hash).exists()
|
||||
await gc.collect(True)
|
||||
assert not blob_path(garbage_hash).exists()
|
||||
assert blob_path(draft_hash).exists() # retained ancestor
|
||||
finally:
|
||||
async with engine.begin() as conn:
|
||||
await conn.execute(text(f'DROP SCHEMA "{schema}" CASCADE'))
|
||||
await engine.dispose()
|
||||
asyncio.run(scenario())
|
||||
@@ -0,0 +1,114 @@
|
||||
import copy
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from app import mods
|
||||
from app.config import get_settings
|
||||
from app.converter.pipeline import convert_archive
|
||||
from app.translation import blob_json, file_json
|
||||
from tests.make_sample import build
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def pg_manifest(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("DATA_DIR", str(tmp_path / "data"))
|
||||
get_settings.cache_clear()
|
||||
blob_json.cache_clear()
|
||||
yield convert_archive(build(tmp_path / "sample.zip"), "sample.zip", tmp_path / "work", lambda _: None).manifest
|
||||
blob_json.cache_clear()
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
def node(id, text):
|
||||
return {"id": id, "op": "say", "tid": "t_" + id, "what": text}
|
||||
|
||||
|
||||
def merge(base, left, right, choices=None):
|
||||
conflicts = []
|
||||
out = mods.merge_value(base, left, right, "", conflicts, choices or {})
|
||||
return out, conflicts
|
||||
|
||||
|
||||
def test_editor_roundtrip_validation_and_anchors(pg_manifest):
|
||||
doc = mods.prepare(mods.document(pg_manifest))
|
||||
assert doc == mods.prepare(mods.document(pg_manifest))
|
||||
mods.validate(doc, pg_manifest)
|
||||
for module in doc["scripts"].values():
|
||||
if module["labels"]:
|
||||
module["labels"][0]["body"].insert(0, node(str(uuid4()), "Inserted"))
|
||||
updated = mods.store(doc, pg_manifest)
|
||||
assert mods.comparable(mods.document(updated))["scripts"] == mods.comparable(doc)["scripts"]
|
||||
assert file_json(pg_manifest, "game.json") == doc["game"]
|
||||
damaged = copy.deepcopy(doc)
|
||||
first = next(m for m in damaged["scripts"].values() if m["labels"])["labels"][0]
|
||||
first["body"].append(copy.deepcopy(first["body"][0]))
|
||||
with pytest.raises(ValueError, match="ID"):
|
||||
mods.validate(damaged, pg_manifest)
|
||||
damaged = copy.deepcopy(doc)
|
||||
next(m for m in damaged["scripts"].values() if m["labels"])["labels"][0]["body"].append({"op": "jump", "id": "bad", "target": "missing_label"})
|
||||
with pytest.raises(ValueError, match="метку"):
|
||||
mods.validate(damaged, pg_manifest)
|
||||
|
||||
|
||||
def test_merge_disjoint_fields_and_insertions():
|
||||
base = [node("a", "A"), node("b", "B"), node("c", "C")]
|
||||
left = [node("a", "Changed"), node("x", "X"), node("b", "B"), node("c", "C")]
|
||||
right = [node("a", "A"), node("b", "B"), node("y", "Y"), node("c", "Changed too")]
|
||||
result, conflicts = merge(base, left, right)
|
||||
assert not conflicts
|
||||
assert [n["id"] for n in result] == ["a", "x", "b", "y", "c"]
|
||||
assert result[0]["what"] == "Changed" and result[-1]["what"] == "Changed too"
|
||||
|
||||
|
||||
def test_conflicting_edit_delete_and_order():
|
||||
base = [node("a", "A"), node("b", "B")]
|
||||
left = [node("a", "LEFT"), node("b", "B")]
|
||||
right = [node("b", "B")]
|
||||
_, conflicts = merge(base, left, right)
|
||||
assert conflicts[0]["path"] == "/@a" and conflicts[0]["rightDeleted"]
|
||||
result, conflicts = merge(base, left, right, {"/@a": "right"})
|
||||
assert result == right and not conflicts
|
||||
_, conflicts = merge(base, [base[0], node("x", "X"), base[1]], [base[0], node("y", "Y"), base[1]])
|
||||
assert conflicts[0]["path"] == "/$order"
|
||||
|
||||
|
||||
def test_asset_conflict_keeps_hash_size_atomic():
|
||||
base = {"hash": "a", "size": 10}
|
||||
left = {"hash": "b", "size": 10}
|
||||
right = {"hash": "c", "size": 20}
|
||||
assert merge(base, left, right, {"": "left"}) == (left, [])
|
||||
|
||||
|
||||
def test_malformed_menu_condition_rejected(pg_manifest):
|
||||
doc = mods.prepare(mods.document(pg_manifest))
|
||||
from app.converter.locales import walk
|
||||
menu = next(n for m in doc["scripts"].values() for l in m["labels"] for n in walk(l["body"]) if n["op"] == "menu")
|
||||
menu["items"][0]["if"] = {"k": "run-javascript"}
|
||||
with pytest.raises(ValueError, match="выражение"):
|
||||
mods.validate(doc, pg_manifest)
|
||||
|
||||
|
||||
def test_optional_nani_expression_contract():
|
||||
from app.pgvn import expression
|
||||
expression({"k": "nani", "ast": ["bin", "+", ["name", "score"], ["const", 1]]})
|
||||
with pytest.raises(ValueError):
|
||||
expression({"k": "nani", "ast": ["call", "eval", [["const", "alert(1)"]]]})
|
||||
|
||||
|
||||
def test_rebase_does_not_trust_positional_ids():
|
||||
def doc(nodes):
|
||||
return {"game": {}, "scripts": {"script/a.json": {"labels": [{"id": "label", "name": "start", "body": nodes}]}}}
|
||||
base = doc([node("1", "A"), node("2", "B")])
|
||||
target = doc([node("1", "Inserted"), node("2", "A"), node("3", "B")])
|
||||
aligned, unmatched = mods.align(base, target)
|
||||
assert not unmatched
|
||||
nodes = aligned["scripts"]["script/a.json"]["labels"][0]["body"]
|
||||
assert nodes[0]["id"] not in ("1", "2")
|
||||
assert [n["id"] for n in nodes[1:]] == ["1", "2"]
|
||||
assert aligned == mods.align(base, target)[0]
|
||||
target["scripts"]["script/a.json"]["labels"][0]["body"][1]["what"] = "Updated A"
|
||||
_, unmatched = mods.align(base, target)
|
||||
assert unmatched[0]["id"] == "1"
|
||||
aligned, _ = mods.align(base, target, {"1": "2"})
|
||||
assert aligned["scripts"]["script/a.json"]["labels"][0]["body"][1]["id"] == "1"
|
||||
@@ -0,0 +1,46 @@
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
|
||||
from app.db import engine as configured_engine
|
||||
|
||||
|
||||
@pytest.mark.skipif(not os.getenv("TEST_POSTGRES"), reason="Set TEST_POSTGRES=1 for isolated database migration test")
|
||||
def test_upgrade_existing_versions_and_downgrade():
|
||||
async def check():
|
||||
name = "migration_test_" + uuid4().hex
|
||||
admin = create_async_engine(configured_engine.url, isolation_level="AUTOCOMMIT")
|
||||
test_url = configured_engine.url.set(database=name)
|
||||
env = {**os.environ, "DATABASE_URL": test_url.render_as_string(hide_password=False)}
|
||||
async def alembic(*args):
|
||||
proc = await asyncio.to_thread(subprocess.run, [sys.executable, "-m", "alembic", *args], env=env, capture_output=True, text=True)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
async with admin.connect() as conn:
|
||||
await conn.execute(text(f'CREATE DATABASE "{name}"'))
|
||||
test_engine = create_async_engine(test_url)
|
||||
try:
|
||||
await alembic("upgrade", "0002")
|
||||
async with test_engine.begin() as conn:
|
||||
await conn.execute(text("INSERT INTO games(id,title,description,tags,engine) VALUES(1,'Old','','{}','renpy')"))
|
||||
await conn.execute(text("INSERT INTO game_versions(id,game_id,name,description,status,progress,size_bytes,manifest) VALUES(1,1,'Original','','ready','',0,CAST(:m AS jsonb)),(2,1,'Legacy mod','','ready','',0,CAST(:m AS jsonb))"), {"m": json.dumps({"format": "pgvn/1", "files": {}})})
|
||||
await conn.execute(text("UPDATE game_versions SET parent_id=1,kind='mod' WHERE id=2"))
|
||||
await alembic("upgrade", "head")
|
||||
async with test_engine.connect() as conn:
|
||||
rows = (await conn.execute(text("SELECT revision_id,base_revision_id FROM game_versions"))).all()
|
||||
assert len(rows) == 2 and all(r[0] and r[1] is None for r in rows)
|
||||
assert await conn.scalar(text("SELECT count(*) FROM revisions")) == 2
|
||||
await alembic("downgrade", "0002")
|
||||
await alembic("upgrade", "head")
|
||||
finally:
|
||||
await test_engine.dispose()
|
||||
async with admin.connect() as conn:
|
||||
await conn.execute(text(f'DROP DATABASE "{name}" WITH (FORCE)'))
|
||||
await admin.dispose()
|
||||
asyncio.run(check())
|
||||
@@ -56,6 +56,19 @@ def test_merge_languages_keeps_target_untouched(manifest):
|
||||
assert "de" not in (tr.file_json(manifest, "game.json").get("locales") or {})
|
||||
|
||||
|
||||
def test_three_way_publication_keeps_independent_lines(manifest):
|
||||
a, b = [line.tid for line in tr.text_index(manifest).lines[:2]]
|
||||
left = tr.apply_changes(manifest, "de", lines={a: {"t": "One"}})
|
||||
right = tr.apply_changes(manifest, "de", lines={b: {"t": "Two"}})
|
||||
merged, conflicts = tr.merge_translation(manifest, left, right)
|
||||
assert not conflicts
|
||||
lines = tr.locale_tables(merged, "de")[0]
|
||||
assert lines[a]["t"] == "One" and lines[b]["t"] == "Two"
|
||||
conflict = tr.apply_changes(manifest, "de", lines={a: {"t": "Other"}})
|
||||
_, conflicts = tr.merge_translation(manifest, left, conflict)
|
||||
assert conflicts and any(a in c["path"] for c in conflicts)
|
||||
|
||||
|
||||
def test_po_and_csv_roundtrip(manifest):
|
||||
tid = tr.text_index(manifest).lines[0].tid
|
||||
m = tr.apply_changes(manifest, "de", lines={tid: {"t": "Zeile 1\nZeile \"2\""}},
|
||||
|
||||
Reference in new issue
Block a user