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.
164 lines
11 KiB
Python
164 lines
11 KiB
Python
"""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())
|