feat(chat): save by default + share anonymously — auto-saved chats, guest-facing Share, success toast, action row
This commit is contained in:
@@ -1,10 +1,13 @@
|
||||
"""Integration: saved-chat CRUD (phase 50, task 02) — the ``/api/chats``
|
||||
contract.
|
||||
|
||||
Real Postgres (``podman compose up -d db``). The router sits behind the
|
||||
phase-16 ``require_admin`` gate exactly like ``/api/steering`` (the
|
||||
house pattern of ``test_steering_api.py``): anonymous callers get 403 on
|
||||
every route; the admin CRUD exercises the auto-title convention (first
|
||||
Real Postgres (``podman compose up -d db``). Phase 55 (task 01) split
|
||||
the phase-16 ``require_admin`` gate: the WRITE surface (``POST`` create
|
||||
incl. save-then-share, ``PUT`` re-Save, ``POST /{id}/share``) is public
|
||||
— the guest pins below exercise exactly that — while the MANAGEMENT
|
||||
surface (list / detail / delete / unshare) stays admin-only (guests get
|
||||
403 on exactly those four routes). The admin CRUD pins stay green
|
||||
unchanged: the auto-title convention (first
|
||||
user message, whitespace-collapsed, 120-char cap + the no-user-message
|
||||
fallback), the list order (``updated_at desc, id desc``), the
|
||||
full-payload round-trip (a ``bor.chat.v1``-shaped brain record carrying
|
||||
@@ -161,29 +164,150 @@ def _assert_no_chats(admin_client: TestClient) -> None:
|
||||
assert admin_client.get("/api/chats").json() == {"chats": []}
|
||||
|
||||
|
||||
# ---------- anonymous: 403 on every route (phase 16 gate) ----------
|
||||
# ---------- guest (no session): the public write surface (phase 55,
|
||||
# task 01 — the router-wide phase-16 gate moved off POST/PUT/share,
|
||||
# onto exactly the four management routes) ----------
|
||||
|
||||
|
||||
def test_anonymous_every_route_returns_403(client: TestClient) -> None:
|
||||
def test_guest_create_returns_201_with_id_and_auto_title(client: TestClient) -> None:
|
||||
"""A guest saves their own conversation (no session cookie): 201,
|
||||
a valid row id, and the same auto-title convention as the admin
|
||||
(first user message) — the save surface is public since phase 55,
|
||||
task 01."""
|
||||
r = client.post("/api/chats", json={"messages": _simple_conversation()})
|
||||
assert r.status_code == 201
|
||||
body = r.json()
|
||||
assert set(body) == OUT_KEYS
|
||||
assert body["title"] == FIRST_QUESTION # auto-title = first user message
|
||||
assert body["message_count"] == 2
|
||||
assert body["messages"] == _expect(_simple_conversation())
|
||||
uuid.UUID(body["id"]) # valid UUID
|
||||
assert "share_url" not in body # unshared: the key is ABSENT
|
||||
|
||||
|
||||
def test_guest_put_replaces_messages_and_bumps_updated_at(client: TestClient) -> None:
|
||||
"""The re-Save upsert is guest-reachable (phase 55, task 01): the
|
||||
same row's messages are fully replaced and ``updated_at`` moves —
|
||||
the auto-save contract (task 02) relies on this working without a
|
||||
session."""
|
||||
created = client.post("/api/chats", json={"messages": _simple_conversation()}).json()
|
||||
updated_before = created["updated_at"]
|
||||
|
||||
time.sleep(0.1) # now() has µs resolution — make the bump observable
|
||||
new_messages = [
|
||||
_user("How do I prune deleted docs?"),
|
||||
{"who": "brain", "text": "Use --prune."},
|
||||
]
|
||||
r = client.put(f"/api/chats/{created['id']}", json={"messages": new_messages})
|
||||
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["id"] == created["id"]
|
||||
assert body["title"] == FIRST_QUESTION # absent title keeps the current one
|
||||
assert body["messages"] == _expect(new_messages) # full replacement
|
||||
assert datetime.fromisoformat(body["updated_at"]) > datetime.fromisoformat(
|
||||
updated_before
|
||||
), "updated_at must bump on a guest re-Save (onupdate=func.now())"
|
||||
|
||||
|
||||
def test_guest_share_returns_share_url_and_is_idempotent(
|
||||
client: TestClient, db: Session
|
||||
) -> None:
|
||||
"""``POST /{id}/share`` is guest-reachable (phase 55, task 01):
|
||||
200 + ``share_url`` (token shape), the token persists on the row,
|
||||
and a second guest share returns the SAME token (idempotent)."""
|
||||
created = client.post("/api/chats", json={"messages": _simple_conversation()}).json()
|
||||
|
||||
r1 = client.post(f"/api/chats/{created['id']}/share")
|
||||
assert r1.status_code == 200
|
||||
body1 = r1.json()
|
||||
assert set(body1) == {"chat_id", "share_url"}
|
||||
assert body1["chat_id"] == created["id"]
|
||||
assert SHARE_URL_RE.fullmatch(body1["share_url"]), (
|
||||
f"share_url must be /shared/<lowercase uuid>: {body1['share_url']}"
|
||||
)
|
||||
token = uuid.UUID(body1["share_url"].removeprefix("/shared/"))
|
||||
assert _stored_token(db, created["id"]) == token # persisted on the row
|
||||
|
||||
r2 = client.post(f"/api/chats/{created['id']}/share")
|
||||
assert r2.status_code == 200
|
||||
assert r2.json() == body1, "a guest re-share returns the SAME token"
|
||||
assert _stored_token(db, created["id"]) == token
|
||||
|
||||
|
||||
def test_guest_create_with_share_saves_and_shares_in_one_action(
|
||||
client: TestClient, db: Session
|
||||
) -> None:
|
||||
"""The save-then-share contract, now guest-reachable (phase 55,
|
||||
task 01): ONE request — 201 + ``share_url``, the token persisted in
|
||||
the SAME commit (no second request), and the link reads
|
||||
anonymously the moment the 201 lands."""
|
||||
r = client.post(
|
||||
"/api/chats", json={"messages": _simple_conversation(), "share": True}
|
||||
)
|
||||
assert r.status_code == 201
|
||||
body = r.json()
|
||||
assert set(body) == OUT_KEYS | {"share_url"}
|
||||
assert body["title"] == FIRST_QUESTION # auto-title applies as for admin
|
||||
assert SHARE_URL_RE.fullmatch(body["share_url"]), (
|
||||
f"share_url must be /shared/<lowercase uuid>: {body['share_url']}"
|
||||
)
|
||||
token = uuid.UUID(body["share_url"].removeprefix("/shared/"))
|
||||
assert _stored_token(db, body["id"]) == token # same commit, one INSERT
|
||||
|
||||
anon = TestClient(fastapi_app) # fresh jar: truly anonymous
|
||||
got = anon.get(f"/api{body['share_url']}")
|
||||
assert got.status_code == 200
|
||||
assert set(got.json()) == SHARED_OUT_KEYS
|
||||
assert got.json()["messages"] == _expect(_simple_conversation())
|
||||
|
||||
|
||||
def test_guest_is_403_only_on_the_management_surface(client: TestClient) -> None:
|
||||
"""The router-wide phase-16 gate MOVED, it did not disappear: a
|
||||
guest (no session cookie) is 403 ``admin only`` on exactly the
|
||||
four management routes — list / detail / delete / unshare (the
|
||||
owner's History surface). The public ``/api/shared/<token>`` read
|
||||
is NOT in this list (it is anonymous by design, as before)."""
|
||||
anon = TestClient(fastapi_app) # fresh jar: truly anonymous
|
||||
unknown = uuid.uuid4()
|
||||
cases = [
|
||||
("GET", "/api/chats", None),
|
||||
("POST", "/api/chats", {"messages": _simple_conversation()}),
|
||||
("GET", f"/api/chats/{unknown}", None),
|
||||
("PUT", f"/api/chats/{unknown}", {"messages": _simple_conversation()}),
|
||||
("DELETE", f"/api/chats/{unknown}", None),
|
||||
("POST", f"/api/chats/{unknown}/share", None),
|
||||
("POST", f"/api/chats/{unknown}/unshare", None),
|
||||
]
|
||||
# The public read is NOT in this list — it is anonymous by design
|
||||
# (a wrong token 404s there, it never 403s).
|
||||
for method, path, body in cases:
|
||||
r = anon.request(method, path, json=body)
|
||||
assert r.status_code == 403, f"{method} {path} must be 403 for anonymous"
|
||||
assert r.status_code == 403, f"{method} {path} must be 403 for a guest"
|
||||
assert r.json() == {"detail": "admin only"}
|
||||
|
||||
|
||||
def test_guest_unshare_403s_but_admin_revocation_still_works(
|
||||
admin_client: TestClient,
|
||||
) -> None:
|
||||
"""Revocation end-to-end across the split gate (phase 55, task
|
||||
01): the guest who created + shared the chat cannot unshare (403 —
|
||||
the link stays live), but the admin's unshare revokes it — the
|
||||
public ``GET /api/shared/<token>`` read 404s afterwards for guest
|
||||
AND admin (the public read itself is unaffected by this task)."""
|
||||
guest = TestClient(fastapi_app) # fresh jar: truly anonymous
|
||||
created = guest.post("/api/chats", json={"messages": _simple_conversation()}).json()
|
||||
share_url = guest.post(f"/api/chats/{created['id']}/share").json()["share_url"]
|
||||
anon = TestClient(fastapi_app) # a second guest, to prove the read
|
||||
assert anon.get(f"/api{share_url}").status_code == 200 # live
|
||||
|
||||
r = guest.post(f"/api/chats/{created['id']}/unshare")
|
||||
assert r.status_code == 403 # management surface — a guest cannot revoke
|
||||
assert r.json() == {"detail": "admin only"}
|
||||
assert anon.get(f"/api{share_url}").status_code == 200 # still live
|
||||
|
||||
assert (
|
||||
admin_client.post(f"/api/chats/{created['id']}/unshare").status_code == 200
|
||||
)
|
||||
assert anon.get(f"/api{share_url}").status_code == 404, "guest: revoked"
|
||||
assert admin_client.get(f"/api{share_url}").status_code == 404, "admin: revoked"
|
||||
|
||||
|
||||
# ---------- create ----------
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user