From 6fe484c44c28c5d5ef050d32a47ca9fed33b2ab9 Mon Sep 17 00:00:00 2001 From: David Shvarts Date: Tue, 7 Apr 2026 20:37:00 +0300 Subject: [PATCH] ref #13: in-memory metadata repositories (S02) --- repository/chat.py | 24 ++++ repository/chat_file.py | 57 +++++++++ repository/workspace.py | 26 +++++ test/test_storage_metadata_repositories.py | 129 +++++++++++++++++++++ 4 files changed, 236 insertions(+) create mode 100644 repository/chat.py create mode 100644 repository/chat_file.py create mode 100644 repository/workspace.py create mode 100644 test/test_storage_metadata_repositories.py diff --git a/repository/chat.py b/repository/chat.py new file mode 100644 index 0000000..1d85cc7 --- /dev/null +++ b/repository/chat.py @@ -0,0 +1,24 @@ +from uuid import UUID + +from domain.chat import Chat +from usecase.interface import ChatRepository + + +class InMemoryChatRepository(ChatRepository): + def __init__(self) -> None: + self._by_id: dict[UUID, Chat] = {} + + def get(self, chat_id: UUID) -> Chat | None: + return self._by_id.get(chat_id) + + def list_by_workspace_id(self, workspace_id: UUID) -> list[Chat]: + return sorted( + (c for c in self._by_id.values() if c.workspace_id == workspace_id), + key=lambda c: (c.created_at, c.chat_id), + ) + + def save(self, chat: Chat) -> None: + self._by_id[chat.chat_id] = chat + + def delete(self, chat_id: UUID) -> None: + self._by_id.pop(chat_id, None) diff --git a/repository/chat_file.py b/repository/chat_file.py new file mode 100644 index 0000000..ca18ce9 --- /dev/null +++ b/repository/chat_file.py @@ -0,0 +1,57 @@ +from uuid import UUID + +from domain.chat import ChatAttachmentName, ChatFile +from usecase.interface import ChatFileRepository + + +class InMemoryChatFileRepository(ChatFileRepository): + def __init__(self) -> None: + self._by_id: dict[UUID, ChatFile] = {} + self._by_chat_and_name: dict[tuple[UUID, str], UUID] = {} + + def get(self, file_id: UUID) -> ChatFile | None: + return self._by_id.get(file_id) + + def get_by_chat_id_and_name( + self, + chat_id: UUID, + name: ChatAttachmentName, + ) -> ChatFile | None: + fid = self._by_chat_and_name.get((chat_id, name.value)) + if fid is None: + return None + return self._by_id.get(fid) + + def list_by_chat_id(self, chat_id: UUID) -> list[ChatFile]: + return sorted( + (f for f in self._by_id.values() if f.chat_id == chat_id), + key=lambda f: (f.created_at, f.file_id), + ) + + def save(self, chat_file: ChatFile) -> None: + key = (chat_file.chat_id, chat_file.name.value) + existing_at_key = self._by_chat_and_name.get(key) + if existing_at_key is not None and existing_at_key != chat_file.file_id: + self._by_id.pop(existing_at_key, None) + + previous = self._by_id.get(chat_file.file_id) + if previous is not None: + prev_key = (previous.chat_id, previous.name.value) + if self._by_chat_and_name.get(prev_key) == previous.file_id: + del self._by_chat_and_name[prev_key] + + self._by_id[chat_file.file_id] = chat_file + self._by_chat_and_name[key] = chat_file.file_id + + def delete(self, file_id: UUID) -> None: + chat_file = self._by_id.pop(file_id, None) + if chat_file is None: + return + key = (chat_file.chat_id, chat_file.name.value) + if self._by_chat_and_name.get(key) == file_id: + del self._by_chat_and_name[key] + + def delete_by_chat_id(self, chat_id: UUID) -> None: + file_ids = [f.file_id for f in self._by_id.values() if f.chat_id == chat_id] + for fid in file_ids: + self.delete(fid) diff --git a/repository/workspace.py b/repository/workspace.py new file mode 100644 index 0000000..4aa5546 --- /dev/null +++ b/repository/workspace.py @@ -0,0 +1,26 @@ +from uuid import UUID + +from domain.workspace import Workspace +from usecase.interface import WorkspaceRepository + + +class InMemoryWorkspaceRepository(WorkspaceRepository): + def __init__(self) -> None: + self._by_id: dict[UUID, Workspace] = {} + self._user_id_to_workspace_id: dict[UUID, UUID] = {} + + def get(self, workspace_id: UUID) -> Workspace | None: + return self._by_id.get(workspace_id) + + def get_by_user_id(self, user_id: UUID) -> Workspace | None: + wid = self._user_id_to_workspace_id.get(user_id) + if wid is None: + return None + return self._by_id.get(wid) + + def save(self, workspace: Workspace) -> None: + existing_wid = self._user_id_to_workspace_id.get(workspace.user_id) + if existing_wid is not None and existing_wid != workspace.workspace_id: + self._by_id.pop(existing_wid, None) + self._by_id[workspace.workspace_id] = workspace + self._user_id_to_workspace_id[workspace.user_id] = workspace.workspace_id diff --git a/test/test_storage_metadata_repositories.py b/test/test_storage_metadata_repositories.py new file mode 100644 index 0000000..b03d56e --- /dev/null +++ b/test/test_storage_metadata_repositories.py @@ -0,0 +1,129 @@ +from datetime import UTC, datetime +from uuid import UUID + +from domain.chat import Chat, ChatAttachmentName, ChatFile +from domain.workspace import Workspace +from repository.chat import InMemoryChatRepository +from repository.chat_file import InMemoryChatFileRepository +from repository.workspace import InMemoryWorkspaceRepository + +USER_A = UUID('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa') +USER_B = UUID('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb') +WS_A = UUID('11111111-1111-1111-1111-111111111111') +WS_B = UUID('22222222-2222-2222-2222-222222222222') +CHAT_A = UUID('33333333-3333-3333-3333-333333333333') +CHAT_B = UUID('44444444-4444-4444-4444-444444444444') +FILE_A = UUID('55555555-5555-5555-5555-555555555555') +FILE_B = UUID('66666666-6666-6666-6666-666666666666') +TS = datetime(2026, 4, 1, 12, 0, 0, tzinfo=UTC) +TS_2 = datetime(2026, 4, 1, 13, 0, 0, tzinfo=UTC) + + +def test_workspace_get_by_user_id() -> None: + repo = InMemoryWorkspaceRepository() + ws = Workspace(workspace_id=WS_A, user_id=USER_A, created_at=TS) + repo.save(ws) + assert repo.get(WS_A) == ws + assert repo.get_by_user_id(USER_A) == ws + assert repo.get_by_user_id(USER_B) is None + + +def test_workspace_replace_for_user() -> None: + repo = InMemoryWorkspaceRepository() + ws = Workspace(workspace_id=WS_A, user_id=USER_A, created_at=TS) + repo.save(ws) + new_ws = Workspace(workspace_id=WS_B, user_id=USER_A, created_at=TS_2) + repo.save(new_ws) + assert repo.get_by_user_id(USER_A) == new_ws + assert repo.get(WS_A) is None + assert repo.get(WS_B) == new_ws + + +def test_chat_crud_workspace_scope() -> None: + chat_repo = InMemoryChatRepository() + chat_a = Chat(chat_id=CHAT_A, workspace_id=WS_A, created_at=TS) + chat_b = Chat(chat_id=CHAT_B, workspace_id=WS_A, created_at=TS_2) + chat_repo.save(chat_a) + chat_repo.save(chat_b) + + listed = chat_repo.list_by_workspace_id(WS_A) + assert listed == [chat_a, chat_b] + + assert chat_repo.get(CHAT_A) == chat_a + chat_repo.delete(CHAT_A) + assert chat_repo.get(CHAT_A) is None + assert chat_repo.list_by_workspace_id(WS_A) == [chat_b] + + +def test_chat_list_only_same_workspace() -> None: + chat_repo = InMemoryChatRepository() + chat_a = Chat(chat_id=CHAT_A, workspace_id=WS_A, created_at=TS) + chat_b = Chat(chat_id=CHAT_B, workspace_id=WS_B, created_at=TS_2) + chat_repo.save(chat_a) + chat_repo.save(chat_b) + assert chat_repo.list_by_workspace_id(WS_A) == [chat_a] + assert chat_repo.list_by_workspace_id(WS_B) == [chat_b] + + +def test_chat_file_metadata_save_get_list_delete_clear() -> None: + name_a = ChatAttachmentName('doc.pdf') + name_b = ChatAttachmentName('x.png') + + repo = InMemoryChatFileRepository() + f_a = ChatFile( + file_id=FILE_A, + chat_id=CHAT_A, + name=name_a, + content_type='application/pdf', + size_bytes=100, + created_at=TS, + ) + f_b = ChatFile( + file_id=FILE_B, + chat_id=CHAT_A, + name=name_b, + content_type='image/png', + size_bytes=200, + created_at=TS_2, + ) + repo.save(f_a) + repo.save(f_b) + + assert repo.get(FILE_A) == f_a + assert repo.get_by_chat_id_and_name(CHAT_A, name_a) == f_a + listed = repo.list_by_chat_id(CHAT_A) + assert listed == [f_a, f_b] + + repo.delete(FILE_A) + assert repo.get(FILE_A) is None + assert repo.get_by_chat_id_and_name(CHAT_A, name_a) is None + + repo.save(f_a) + repo.delete_by_chat_id(CHAT_A) + assert repo.list_by_chat_id(CHAT_A) == [] + + +def test_chat_file_same_name_replaced_by_new_id() -> None: + name = ChatAttachmentName('a.txt') + repo = InMemoryChatFileRepository() + first = ChatFile( + file_id=FILE_A, + chat_id=CHAT_A, + name=name, + content_type='text/plain', + size_bytes=1, + created_at=TS, + ) + second = ChatFile( + file_id=FILE_B, + chat_id=CHAT_A, + name=name, + content_type='text/plain', + size_bytes=2, + created_at=TS_2, + ) + repo.save(first) + repo.save(second) + assert repo.get(FILE_A) is None + assert repo.get(FILE_B) == second + assert repo.get_by_chat_id_and_name(CHAT_A, name) == second