"""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())