264 lines
8.3 KiB
Python
264 lines
8.3 KiB
Python
"""B2B API integration tests: API-key auth, upload, report polling, management.
|
|
|
|
Run against the Docker Compose infrastructure (`docker compose up -d`).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import time
|
|
import uuid
|
|
from pathlib import Path
|
|
|
|
import aio_pika
|
|
import httpx
|
|
import pytest
|
|
|
|
from tests.integration.conftest import user_token
|
|
|
|
pytestmark = pytest.mark.integration
|
|
|
|
|
|
@pytest.fixture
|
|
def pdf_bytes(tmp_path: Path) -> bytes:
|
|
import pymupdf
|
|
|
|
doc = pymupdf.open()
|
|
page = doc.new_page()
|
|
page.insert_text((72, 72), "Договор. Стороны обязуются.")
|
|
path = tmp_path / "contract.pdf"
|
|
doc.save(str(path))
|
|
doc.close()
|
|
return path.read_bytes()
|
|
|
|
|
|
@pytest.fixture
|
|
async def api_key_client(
|
|
client: httpx.AsyncClient,
|
|
db_session, # noqa: ANN001
|
|
infra: dict[str, str],
|
|
) -> tuple[httpx.AsyncClient, str, int]:
|
|
"""Create a user, authenticate, create an API key, and return (client, key, telegram_id)."""
|
|
from sqlalchemy import text
|
|
|
|
from contract_check.core.api_keys import hash_api_key
|
|
|
|
telegram_id = 999000999
|
|
await db_session.execute(
|
|
text(
|
|
"INSERT INTO users (telegram_id, credits_left) VALUES (:t, 10) "
|
|
"ON CONFLICT (telegram_id) DO UPDATE SET credits_left = 10"
|
|
),
|
|
{"t": telegram_id},
|
|
)
|
|
# Clean any leftover key from a previous interrupted run.
|
|
await db_session.execute(
|
|
text(
|
|
"DELETE FROM api_keys "
|
|
"WHERE user_id = (SELECT id FROM users WHERE telegram_id = :t) "
|
|
"AND name = :name"
|
|
),
|
|
{"t": telegram_id, "name": "integration-test-key"},
|
|
)
|
|
await db_session.commit()
|
|
|
|
token = await user_token(client, infra, telegram_id)
|
|
|
|
create_resp = await client.post(
|
|
"/api/v1/b2b/keys",
|
|
headers={"Authorization": f"Bearer {token}"},
|
|
json={"name": "integration-test-key"},
|
|
)
|
|
assert create_resp.status_code == 201
|
|
body = create_resp.json()
|
|
raw_key = body["api_key"]
|
|
|
|
# Confirm hash is stored.
|
|
result = await db_session.execute(
|
|
text("SELECT id FROM api_keys WHERE key_hash = :h"),
|
|
{"h": hash_api_key(raw_key)},
|
|
)
|
|
assert result.first() is not None
|
|
|
|
return client, raw_key, telegram_id
|
|
|
|
|
|
async def test_b2b_analyze_upload_returns_202_and_enqueues(
|
|
api_key_client: tuple[httpx.AsyncClient, str, int],
|
|
pdf_bytes: bytes,
|
|
infra: dict[str, str],
|
|
) -> None:
|
|
client, api_key, _telegram_id = api_key_client
|
|
|
|
response = await client.post(
|
|
"/api/v1/analyze",
|
|
headers={"X-API-Key": api_key},
|
|
files={"file": ("contract.pdf", pdf_bytes, "application/pdf")},
|
|
)
|
|
assert response.status_code == 202
|
|
body = response.json()
|
|
document_id = uuid.UUID(body["document_id"])
|
|
correlation_id = uuid.UUID(body["correlation_id"])
|
|
assert body["credits_left"] == 9
|
|
|
|
# Verify a DocumentUploaded message landed on extract.q (drain leftovers).
|
|
connection = await aio_pika.connect_robust(infra["rabbitmq_url"])
|
|
try:
|
|
channel = await connection.channel()
|
|
queue = await channel.get_queue("extract.q", ensure=False)
|
|
deadline = 10.0
|
|
found = False
|
|
while deadline > 0:
|
|
start = time.monotonic()
|
|
try:
|
|
message = await queue.get(timeout=deadline)
|
|
except aio_pika.exceptions.QueueEmpty:
|
|
# basic_get is non-blocking; the routed message may not be
|
|
# visible yet. Treat as transient and retry until the deadline.
|
|
await asyncio.sleep(0.25)
|
|
deadline -= time.monotonic() - start
|
|
continue
|
|
await message.ack()
|
|
msg_body = message.body.decode("utf-8")
|
|
if str(document_id) in msg_body:
|
|
assert str(correlation_id) in msg_body
|
|
found = True
|
|
break
|
|
deadline -= time.monotonic() - start
|
|
assert found, "expected DocumentUploaded message not found in extract.q"
|
|
finally:
|
|
await connection.close()
|
|
|
|
|
|
async def test_b2b_get_report_before_ready_returns_status(
|
|
api_key_client: tuple[httpx.AsyncClient, str, int],
|
|
pdf_bytes: bytes,
|
|
) -> None:
|
|
client, api_key, _telegram_id = api_key_client
|
|
|
|
upload = await client.post(
|
|
"/api/v1/analyze",
|
|
headers={"X-API-Key": api_key},
|
|
files={"file": ("contract.pdf", pdf_bytes, "application/pdf")},
|
|
)
|
|
assert upload.status_code == 202
|
|
document_id = upload.json()["document_id"]
|
|
|
|
poll = await client.get(
|
|
f"/api/v1/b2b/reports/{document_id}",
|
|
headers={"X-API-Key": api_key},
|
|
)
|
|
assert poll.status_code == 200
|
|
body = poll.json()
|
|
assert body["document_id"] == document_id
|
|
assert body["status"] in ("queued", "extracting")
|
|
assert "stage" in body
|
|
|
|
|
|
async def test_b2b_missing_api_key_returns_401(client: httpx.AsyncClient) -> None:
|
|
response = await client.get("/api/v1/b2b/usage")
|
|
assert response.status_code == 401
|
|
|
|
|
|
async def test_b2b_invalid_api_key_returns_401(client: httpx.AsyncClient) -> None:
|
|
response = await client.get(
|
|
"/api/v1/b2b/usage",
|
|
headers={"X-API-Key": "clearly-invalid-key"},
|
|
)
|
|
assert response.status_code == 401
|
|
|
|
|
|
async def test_b2b_key_management_requires_user_jwt(
|
|
client: httpx.AsyncClient,
|
|
) -> None:
|
|
response = await client.post(
|
|
"/api/v1/b2b/keys",
|
|
json={"name": "no-auth-key"},
|
|
)
|
|
assert response.status_code == 401
|
|
|
|
|
|
async def test_b2b_key_usage_aggregated_by_month(
|
|
api_key_client: tuple[httpx.AsyncClient, str, int],
|
|
infra: dict[str, str],
|
|
db_session, # noqa: ANN001
|
|
) -> None:
|
|
from sqlalchemy import text
|
|
|
|
client, api_key, telegram_id = api_key_client
|
|
|
|
token = await user_token(client, infra, telegram_id)
|
|
list_resp = await client.get(
|
|
"/api/v1/b2b/keys",
|
|
headers={"Authorization": f"Bearer {token}"},
|
|
)
|
|
assert list_resp.status_code == 200
|
|
key_id = uuid.UUID(list_resp.json()[0]["id"])
|
|
|
|
# Seed two requests last month and one this month, each linked to a document.
|
|
result = await db_session.execute(
|
|
text("SELECT id FROM users WHERE telegram_id = :t"), {"t": telegram_id}
|
|
)
|
|
user_id = result.scalar_one()
|
|
doc_ids = [uuid.uuid4() for _ in range(3)]
|
|
await db_session.execute(
|
|
text(
|
|
"INSERT INTO documents (id, user_id, s3_key, filename, mime, bytes, status) "
|
|
"VALUES (:id1, :u, 's3/1', '1.pdf', 'application/pdf', 1, 'done'), "
|
|
"(:id2, :u, 's3/2', '2.pdf', 'application/pdf', 1, 'done'), "
|
|
"(:id3, :u, 's3/3', '3.pdf', 'application/pdf', 1, 'done')"
|
|
),
|
|
{"id1": doc_ids[0], "id2": doc_ids[1], "id3": doc_ids[2], "u": user_id},
|
|
)
|
|
await db_session.execute(
|
|
text(
|
|
"INSERT INTO api_key_requests (api_key_id, document_id, created_at) "
|
|
"VALUES (:k, :d1, now() - interval '1 month'), "
|
|
"(:k, :d2, now() - interval '1 month'), "
|
|
"(:k, :d3, now())"
|
|
),
|
|
{"k": key_id, "d1": doc_ids[0], "d2": doc_ids[1], "d3": doc_ids[2]},
|
|
)
|
|
await db_session.commit()
|
|
|
|
response = await client.get(
|
|
f"/api/v1/b2b/keys/{key_id}/usage",
|
|
headers={"Authorization": f"Bearer {token}"},
|
|
)
|
|
assert response.status_code == 200
|
|
body = response.json()
|
|
assert len(body["monthly_requests"]) == 2
|
|
counts = sorted(entry["requests"] for entry in body["monthly_requests"])
|
|
assert counts == [1, 2]
|
|
|
|
|
|
async def test_b2b_revoke_key_blocks_usage(
|
|
api_key_client: tuple[httpx.AsyncClient, str, int],
|
|
infra: dict[str, str],
|
|
pdf_bytes: bytes,
|
|
) -> None:
|
|
client, api_key, telegram_id = api_key_client
|
|
|
|
token = await user_token(client, infra, telegram_id)
|
|
|
|
# List keys to find id.
|
|
list_resp = await client.get(
|
|
"/api/v1/b2b/keys",
|
|
headers={"Authorization": f"Bearer {token}"},
|
|
)
|
|
assert list_resp.status_code == 200
|
|
key_id = list_resp.json()[0]["id"]
|
|
|
|
revoke = await client.post(
|
|
f"/api/v1/b2b/keys/{key_id}/revoke",
|
|
headers={"Authorization": f"Bearer {token}"},
|
|
)
|
|
assert revoke.status_code == 200
|
|
|
|
# Revoked key cannot upload.
|
|
response = await client.post(
|
|
"/api/v1/analyze",
|
|
headers={"X-API-Key": api_key},
|
|
files={"file": ("contract.pdf", pdf_bytes, "application/pdf")},
|
|
)
|
|
assert response.status_code == 401
|