"""Temporary chats: hidden from the sidebar, and swept a day later.""" from __future__ import annotations from datetime import UTC, datetime, timedelta from fastapi.testclient import TestClient from sqlalchemy import select from lembas.db.models import Chat, Connection, Folder, Message, Model from lembas.services import chat as chat_service from lembas.services.crypto import encrypt def _connection(db) -> Connection: connection = Connection( name="Test", base_url="http://127.0.0.1:1", api_key_encrypted=encrypt("") ) db.add(connection) db.commit() db.add(Model(connection_id=connection.id, model_id="test-model")) db.commit() return connection def _temporary(db, chat_id: str, *, title: str = "Passing thought") -> Chat: chat = db.get(Chat, chat_id) chat.temporary = True chat.title = title db.commit() return chat # --- Hidden ------------------------------------------------------------------ def test_a_temporary_chat_is_not_in_the_sidebar(client: TestClient, db, registered, make_chat): _connection(db) _temporary(db, make_chat()) assert "Passing thought" not in client.get("/chat").text def test_a_temporary_chat_inside_a_folder_is_not_listed( client: TestClient, db, registered, make_chat ): """The folder branch renders through the relationship, which is why this needs asserting separately from the unfiled list.""" _connection(db) client.post("/api/folders", data={"name": "Quests"}) folder = db.scalar(select(Folder)) chat_id = make_chat() client.patch(f"/api/chats/{chat_id}", data={"folder_id": folder.id}) _temporary(db, chat_id) page = client.get("/chat").text assert "Passing thought" not in page assert "Empty" in page def test_a_temporary_chat_gets_no_unread_dot(client: TestClient, db, registered, make_chat): """There is no sidebar row for the dot, and the toast would name a chat nobody can navigate to.""" _connection(db) chat = _temporary(db, make_chat()) chat.unread = True db.commit() response = client.get("/api/chats/unread") assert chat.id not in response.text assert "HX-Trigger" not in response.headers def test_user_chats_excludes_temporary_ones(db, registered, make_chat, user_id): _connection(db) _temporary(db, make_chat()) assert chat_service.user_chats(db, user_id) == [] def test_a_finished_temporary_reply_is_not_unread(db, registered, make_chat): from lembas.services import generation as generation_service _connection(db) chat_id = make_chat() _temporary(db, chat_id) reply = Message(chat_id=chat_id, role="assistant", content="", complete=False) db.add(reply) db.commit() generation = generation_service.Generation(chat_id=chat_id, message_id=reply.id) generation.content.append("Waybread.") generation_service._persist(generation, "", 0.0) db.expire_all() assert db.get(Chat, chat_id).unread is False # --- Starting one ------------------------------------------------------------- def test_the_new_chat_screen_carries_the_flag(client: TestClient, db, registered): _connection(db) assert 'name="temporary"' not in client.get("/chat").text assert 'name="temporary"' in client.get("/chat?temporary=1").text def test_starting_a_temporary_chat_sets_the_flag(client: TestClient, db, registered): _connection(db) client.post("/api/chats/start", data={"content": "hello", "temporary": "true"}) assert db.scalar(select(Chat)).temporary is True def test_starting_an_ordinary_chat_does_not(client: TestClient, db, registered): _connection(db) client.post("/api/chats/start", data={"content": "hello"}) assert db.scalar(select(Chat)).temporary is False # --- Keeping one -------------------------------------------------------------- def test_keeping_a_chat_clears_the_flag(client: TestClient, db, registered, make_chat): _connection(db) chat = _temporary(db, make_chat()) response = client.post(f"/api/chats/{chat.id}/keep") assert response.status_code == 204 assert response.headers["HX-Refresh"] == "true" db.refresh(chat) assert chat.temporary is False assert "Passing thought" in client.get("/chat").text def test_keeping_someone_elses_chat_is_not_found(client: TestClient, db, registered, make_chat): from lembas.db.models import User from lembas.security.passwords import hash_password _connection(db) chat = _temporary(db, make_chat()) other = User(name="Sam", email="s@shire.test", password_hash=hash_password("x")) db.add(other) db.commit() chat.user_id = other.id db.commit() assert client.post(f"/api/chats/{chat.id}/keep").status_code == 404 # --- The sweep ---------------------------------------------------------------- def _aged(db, chat_id: str, hours: float) -> None: """Backdate the chat's newest message, which is what the sweep measures.""" when = datetime.now(UTC) - timedelta(hours=hours) message = Message(chat_id=chat_id, role="user", content="hello", created_at=when) db.add(message) db.commit() def test_the_sweep_removes_a_stale_temporary_chat(db, registered, make_chat): _connection(db) chat_id = make_chat() _temporary(db, chat_id) _aged(db, chat_id, 25) assert chat_service.sweep_temporary(db) == 1 assert db.get(Chat, chat_id) is None def test_the_sweep_keeps_one_still_in_use(db, registered, make_chat): """Age is measured from the newest message. A conversation still going at hour 23 must not vanish mid-sentence.""" _connection(db) chat_id = make_chat() _temporary(db, chat_id) _aged(db, chat_id, 40) _aged(db, chat_id, 0.5) assert chat_service.sweep_temporary(db) == 0 assert db.get(Chat, chat_id) is not None def test_the_sweep_leaves_ordinary_chats_alone(db, registered, make_chat): _connection(db) chat_id = make_chat() _aged(db, chat_id, 500) assert chat_service.sweep_temporary(db) == 0 assert db.get(Chat, chat_id) is not None def test_a_temporary_chat_with_no_messages_ages_from_its_own_row(db, registered, make_chat): _connection(db) chat_id = make_chat() chat = _temporary(db, chat_id) chat.created_at = datetime.now(UTC) - timedelta(hours=30) db.commit() assert chat_service.sweep_temporary(db) == 1 def test_the_sweep_unlinks_the_files_too(client: TestClient, db, registered, make_chat): """Deleting a chat cascades the rows but leaves the files on disk. Anything that deletes chats has to remove them while the rows still say which.""" from lembas.db.models import Attachment from lembas.services.files import stored_path _connection(db) chat_id = make_chat() client.post("/api/files", files={"file": ("notes.txt", b"some words", "text/plain")}) attachment = db.scalar(select(Attachment)) attachment.chat_id = chat_id message = Message(chat_id=chat_id, role="user", content="look") db.add(message) db.commit() attachment.message_id = message.id db.commit() path = stored_path(attachment.stored_name) assert path is not None and path.exists() _temporary(db, chat_id) db.query(Message).filter(Message.id == message.id).update( {"created_at": datetime.now(UTC) - timedelta(hours=30)} ) db.commit() chat_service.sweep_temporary(db) assert not path.exists()