Files
mrleo1nidandClaude Sonnet 5.5 bbbcc6a5d8 chore: add CI, lint config, login rate limit and split large modules
- 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>
2026-10-25 11:23:22 +03:00

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