mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-04 12:13:05 +00:00
The backend import package is now docsgpt, the name it will carry on PyPI; application was far too generic to install into anyone's site-packages. git mv plus a mechanical rewrite of every import, dotted string and path reference: 734 Python files, the compose files, Dockerfile, workflows, docs, setup scripts, devcontainer, k8s manifests, vscode config, pytest and coverage config, .gitignore. Behaviour is unchanged. Kept for one release: - A top-level application package whose meta-path finder resolves application.x.y to the already-imported docsgpt.x.y object, so old imports and entry points (celery -A application.app.celery, uvicorn application.asgi:asgi_app) keep working with a FutureWarning. - Celery registers every application.* task name as an alias of its docsgpt.* task on start-up, so messages queued by the previous release still run. The redbeat key prefix moves to redbeat:docsgpt:v2: so schedule entries the previous release wrote are left unread instead of firing twice. The backend image builds from the repository root (docker build -f docsgpt/Dockerfile .) so it can ship the alias package; a root .dockerignore allow-lists docsgpt/ and application/ and keeps caches, local data, .env files, the sample index files and the Dockerfile out. Compose and the image workflows point at the new context.
191 lines
7.1 KiB
Python
191 lines
7.1 KiB
Python
"""Integration tests for the end-to-end snapshot+tail handoff.
|
|
|
|
Exercises the publisher → journal → reconnect-reader round-trip without
|
|
mocking the journal layer, so a regression in any of:
|
|
- complete_stream's _emit closure
|
|
- record_event's commit-per-call contract
|
|
- the shared snapshot read (``event_replay.read_snapshot_lines``)
|
|
- the reconnect route's ownership SQL (``async_sse._user_owns_message``)
|
|
- message_events repo SQL
|
|
would surface here as a failed integration assertion.
|
|
|
|
The live tail itself (pub/sub) and the full async HTTP route are covered by
|
|
``scripts/e2e_async_sse.py`` against a real Redis + uvicorn. These tests pin
|
|
the DB-backed halves against a transactional ``pg_conn`` fixture.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import uuid as _uuid
|
|
from contextlib import contextmanager
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
from sqlalchemy import text as sql_text
|
|
|
|
|
|
@contextmanager
|
|
def _patch_journal_session(conn):
|
|
@contextmanager
|
|
def _yield():
|
|
yield conn
|
|
|
|
with patch(
|
|
"docsgpt.streaming.message_journal.db_session", _yield
|
|
), patch(
|
|
"docsgpt.streaming.event_replay.db_readonly", _yield
|
|
):
|
|
yield
|
|
|
|
|
|
def _seed_message(conn, user_id: str | None = None):
|
|
user_id = user_id or f"u-{_uuid.uuid4().hex[:6]}"
|
|
conv_id = _uuid.uuid4()
|
|
msg_id = _uuid.uuid4()
|
|
conn.execute(sql_text("INSERT INTO users (user_id) VALUES (:u)"), {"u": user_id})
|
|
conn.execute(
|
|
sql_text(
|
|
"INSERT INTO conversations (id, user_id, name) VALUES (:id, :u, 't')"
|
|
),
|
|
{"id": conv_id, "u": user_id},
|
|
)
|
|
conn.execute(
|
|
sql_text(
|
|
"INSERT INTO conversation_messages (id, conversation_id, user_id, position) "
|
|
"VALUES (:id, :c, :u, 0)"
|
|
),
|
|
{"id": msg_id, "c": conv_id, "u": user_id},
|
|
)
|
|
return user_id, str(msg_id)
|
|
|
|
|
|
def _emitted_ids(lines: list[str]) -> list[int]:
|
|
"""Extract the ``id:`` sequence numbers from formatted SSE frames."""
|
|
return sorted(
|
|
int(line.split("\n", 1)[0].split(": ", 1)[1])
|
|
for line in lines
|
|
if line.startswith("id: ")
|
|
)
|
|
|
|
|
|
@pytest.mark.integration
|
|
class TestSnapshotPlusTailRoundTrip:
|
|
def test_record_event_then_snapshot_returns_what_was_journaled(
|
|
self, pg_conn,
|
|
):
|
|
"""End-to-end of the journal half: ``record_event`` writes through a
|
|
real ``MessageEventsRepository``, ``read_snapshot_lines`` reads the
|
|
snapshot back via the same repo and formats it for the wire — the
|
|
exact primitive the async reader replays through.
|
|
"""
|
|
from docsgpt.streaming.event_replay import read_snapshot_lines
|
|
from docsgpt.streaming.message_journal import record_event
|
|
|
|
_, message_id = _seed_message(pg_conn)
|
|
|
|
with _patch_journal_session(pg_conn):
|
|
record_event(message_id, 0, "answer", {"type": "answer", "answer": "A"})
|
|
record_event(message_id, 1, "answer", {"type": "answer", "answer": "B"})
|
|
record_event(message_id, 2, "end", {"type": "end"})
|
|
|
|
lines, max_seq, terminal = read_snapshot_lines(message_id, None)
|
|
|
|
assert len(lines) == 3
|
|
assert "id: 0" in lines[0] and '"answer": "A"' in lines[0]
|
|
assert "id: 1" in lines[1] and '"answer": "B"' in lines[1]
|
|
assert "id: 2" in lines[2] and '"type": "end"' in lines[2]
|
|
assert max_seq == 2
|
|
# A terminal event in the snapshot tells the reader to close.
|
|
assert terminal is True
|
|
|
|
def test_snapshot_resumes_past_last_event_id(self, pg_conn):
|
|
from docsgpt.streaming.event_replay import read_snapshot_lines
|
|
from docsgpt.streaming.message_journal import record_event
|
|
|
|
_, message_id = _seed_message(pg_conn)
|
|
|
|
with _patch_journal_session(pg_conn):
|
|
for seq in range(5):
|
|
record_event(
|
|
message_id, seq, "answer", {"type": "answer", "answer": str(seq)}
|
|
)
|
|
|
|
# Client says it has seen up through seq=2; expect 3 + 4.
|
|
lines, max_seq, terminal = read_snapshot_lines(message_id, 2)
|
|
|
|
assert _emitted_ids(lines) == [3, 4]
|
|
assert max_seq == 4
|
|
assert terminal is False
|
|
|
|
def test_ownership_sql_accepts_owner_and_rejects_others(self, pg_conn):
|
|
"""The async route's ownership gate runs real SQL against
|
|
``conversation_messages`` — the owner passes, everyone else 404s.
|
|
"""
|
|
from docsgpt.api import async_sse
|
|
|
|
user_id, message_id = _seed_message(pg_conn)
|
|
|
|
@contextmanager
|
|
def _yield():
|
|
yield pg_conn
|
|
|
|
with patch("docsgpt.api.async_sse.db_readonly", _yield):
|
|
assert async_sse._user_owns_message(message_id, user_id) is True
|
|
assert async_sse._user_owns_message(message_id, "different-user") is False
|
|
# A well-formed but unknown id is also not owned.
|
|
assert async_sse._user_owns_message(str(_uuid.uuid4()), user_id) is False
|
|
|
|
def test_snapshot_read_is_user_scoped(self, pg_conn):
|
|
"""``read_snapshot_lines(..., user_id=)`` re-asserts ownership at the
|
|
data layer: the owner gets the journal rows, a non-owner gets none.
|
|
"""
|
|
from docsgpt.streaming.event_replay import read_snapshot_lines
|
|
from docsgpt.streaming.message_journal import record_event
|
|
|
|
user_id, message_id = _seed_message(pg_conn)
|
|
|
|
with _patch_journal_session(pg_conn):
|
|
record_event(message_id, 0, "answer", {"type": "answer", "answer": "x"})
|
|
record_event(message_id, 1, "end", {"type": "end"})
|
|
|
|
owner_lines, _, owner_terminal = read_snapshot_lines(
|
|
message_id, None, user_id
|
|
)
|
|
other_lines, _, other_terminal = read_snapshot_lines(
|
|
message_id, None, "different-user"
|
|
)
|
|
# Unscoped (user_id=None) still returns everything.
|
|
unscoped_lines, _, _ = read_snapshot_lines(message_id, None)
|
|
|
|
assert _emitted_ids(owner_lines) == [0, 1] and owner_terminal is True
|
|
assert other_lines == [] and other_terminal is False
|
|
assert _emitted_ids(unscoped_lines) == [0, 1]
|
|
|
|
def test_watchdog_is_user_scoped(self, pg_conn):
|
|
"""``_check_producer_liveness(..., user_id)`` only sees the caller's
|
|
own row; a non-owner reads as missing (terminal), not as the row's
|
|
real status.
|
|
"""
|
|
from docsgpt.streaming.event_replay import _check_producer_liveness
|
|
|
|
# Seed a row and flip it to a terminal status the watchdog reports.
|
|
user_id, message_id = _seed_message(pg_conn)
|
|
pg_conn.execute(
|
|
sql_text(
|
|
"UPDATE conversation_messages SET status='complete' WHERE id = :id"
|
|
),
|
|
{"id": message_id},
|
|
)
|
|
|
|
@contextmanager
|
|
def _yield():
|
|
yield pg_conn
|
|
|
|
with patch("docsgpt.streaming.event_replay.db_readonly", _yield):
|
|
owner = _check_producer_liveness(message_id, user_id, 90.0)
|
|
other = _check_producer_liveness(message_id, "different-user", 90.0)
|
|
|
|
# Owner sees the real terminal state; non-owner sees "missing".
|
|
assert owner == {"type": "end"}
|
|
assert other is not None and other.get("code") == "message_missing"
|