- Rate-limit failed logins (8 per 15 min per IP or username, 429 + Retry-After) - Add ruff/mypy config and GitHub Actions workflow (ruff, pytest, tsc, tests, build) - Apply ruff autofixes (import order, pyupgrade) - Extract rpy_helpers.py, engine-util.ts, TranslationParts.tsx and translation-types.ts from oversized modules - Add dependency-free React UI tests - Refresh README and ARCHITECTURE (Naninovel/Tomie, status and plans) Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
164 lines
11 KiB
Python
164 lines
11 KiB
Python
"""Integration against PostgreSQL in a temporary schema, never touches library rows."""
|
|
import asyncio
|
|
import os
|
|
from uuid import uuid4
|
|
|
|
import httpx
|
|
import pytest
|
|
from sqlalchemy import 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())
|