diff --git a/tests/e2e/test_password_manager_window.py b/tests/e2e/test_password_manager_window.py new file mode 100644 index 0000000..2660ae5 --- /dev/null +++ b/tests/e2e/test_password_manager_window.py @@ -0,0 +1,65 @@ +import tkinter as tk +from collections.abc import Iterator +from pathlib import Path + +import pytest + +from password_manager import constants +from password_manager.application.use_cases import ( + FindPasswordEntry, + GeneratePassword, + SavePasswordEntry, +) +from password_manager.infrastructure.persistence.json_password_repository import ( + JsonPasswordRepository, +) +from password_manager.presentation.features.password_manager import ( + password_manager_view as view_module, +) +from password_manager.presentation.features.password_manager import ( + password_manager_view_model as view_model_module, +) + + +@pytest.fixture +def root() -> Iterator[tk.Tk]: + try: + window = tk.Tk() + except tk.TclError: + pytest.skip("no display available") + window.withdraw() + yield window + window.destroy() + + +def test_save_then_search_through_window( + root: tk.Tk, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + shown: list[str] = [] + monkeypatch.setattr(view_module.messagebox, "askokcancel", lambda **_: True) + monkeypatch.setattr( + view_module.messagebox, "showinfo", lambda **kw: shown.append(kw["message"]) + ) + repository = JsonPasswordRepository(tmp_path / "data.json") + view = view_module.PasswordManagerView( + root, + view_model_module.PasswordManagerViewModel( + SavePasswordEntry(repository), + FindPasswordEntry(repository), + GeneratePassword(), + ), + ) + view.website_entry.insert(0, "example.com") + view.generate_password() + password = view.password_entry.get() + assert root.clipboard_get() == password + + view.save() + + assert view.website_entry.get() == "" + assert view.password_entry.get() == "" + view.website_entry.insert(0, "example.com") + view.find_password() + assert shown[-1] == constants.MESSAGE_DETAILS.format( + email=constants.DEFAULT_EMAIL, password=password + ) diff --git a/tests/integration/test_json_password_repository.py b/tests/integration/test_json_password_repository.py new file mode 100644 index 0000000..be0e384 --- /dev/null +++ b/tests/integration/test_json_password_repository.py @@ -0,0 +1,111 @@ +import json +from pathlib import Path + +import pytest + +from password_manager.domain.errors import ( + DataFileNotFoundError, + EntryNotFoundError, + InvalidDataFileError, +) +from password_manager.domain.password_entry import PasswordEntry +from password_manager.infrastructure.persistence.json_password_repository import ( + JsonPasswordRepository, +) + +ENTRY = PasswordEntry("example.com", "me@x.org", "pw1") + + +def test_add_writes_nested_json_and_reads_back(tmp_path: Path) -> None: + path = tmp_path / "data.json" + repository = JsonPasswordRepository(path) + + repository.add(ENTRY) + + assert json.loads(path.read_text("utf-8")) == { + "example.com": {"email": "me@x.org", "password": "pw1"} + } + assert repository.find("example.com") == ENTRY + + +def test_add_uses_indent_of_four(tmp_path: Path) -> None: + path = tmp_path / "data.json" + JsonPasswordRepository(path).add(ENTRY) + + assert '\n "example.com"' in path.read_text("utf-8") + + +def test_add_keeps_existing_entries(tmp_path: Path) -> None: + repository = JsonPasswordRepository(tmp_path / "data.json") + other = PasswordEntry("other.org", "o@x.org", "pw2") + repository.add(ENTRY) + + repository.add(other) + + assert repository.find("example.com") == ENTRY + assert repository.find("other.org") == other + + +def test_add_creates_missing_file(tmp_path: Path) -> None: + path = tmp_path / "data.json" + assert not path.exists() + + JsonPasswordRepository(path).add(ENTRY) + + assert path.exists() + + +def test_add_rewrites_empty_file(tmp_path: Path) -> None: + path = tmp_path / "data.json" + path.write_text("", encoding="utf-8") + + JsonPasswordRepository(path).add(ENTRY) + + assert JsonPasswordRepository(path).find("example.com") == ENTRY + + +def test_add_rewrites_invalid_json(tmp_path: Path) -> None: + path = tmp_path / "data.json" + path.write_text("{not json", encoding="utf-8") + + JsonPasswordRepository(path).add(ENTRY) + + assert JsonPasswordRepository(path).find("example.com") == ENTRY + + +def test_add_refuses_json_that_is_not_an_object(tmp_path: Path) -> None: + path = tmp_path / "data.json" + path.write_text("[1, 2]", encoding="utf-8") + + with pytest.raises(InvalidDataFileError): + JsonPasswordRepository(path).add(ENTRY) + assert path.read_text("utf-8") == "[1, 2]" + + +def test_find_raises_when_file_missing(tmp_path: Path) -> None: + with pytest.raises(DataFileNotFoundError): + JsonPasswordRepository(tmp_path / "data.json").find("example.com") + + +def test_find_raises_when_website_absent(tmp_path: Path) -> None: + repository = JsonPasswordRepository(tmp_path / "data.json") + repository.add(ENTRY) + + with pytest.raises(EntryNotFoundError): + repository.find("nope.com") + + +def test_find_raises_not_found_on_empty_file(tmp_path: Path) -> None: + path = tmp_path / "data.json" + path.write_text("", encoding="utf-8") + + with pytest.raises(EntryNotFoundError): + JsonPasswordRepository(path).find("example.com") + + +def test_find_raises_when_entry_lacks_keys(tmp_path: Path) -> None: + path = tmp_path / "data.json" + path.write_text('{"example.com": {"email": "e"}}', encoding="utf-8") + + with pytest.raises(InvalidDataFileError): + JsonPasswordRepository(path).find("example.com") diff --git a/tests/unit/application/test_use_cases.py b/tests/unit/application/test_use_cases.py new file mode 100644 index 0000000..eb0e923 --- /dev/null +++ b/tests/unit/application/test_use_cases.py @@ -0,0 +1,68 @@ +import random + +import pytest +from conftest import FakePasswordRepository + +from password_manager.application.use_cases import ( + FindPasswordEntry, + GeneratePassword, + SavePasswordEntry, +) +from password_manager.domain.errors import ( + DataFileNotFoundError, + EmptyFieldError, + EntryNotFoundError, +) +from password_manager.domain.password_entry import PasswordEntry + + +def test_save_stores_entry(repository: FakePasswordRepository) -> None: + SavePasswordEntry(repository).execute("example.com", "me@x.org", "pw") + + assert repository.entries["example.com"] == PasswordEntry( + "example.com", "me@x.org", "pw" + ) + + +@pytest.mark.parametrize( + ("website", "email", "password"), + [("", "e", "p"), ("w", "", "p"), ("w", "e", ""), (" ", "e", "p")], +) +def test_save_rejects_empty_field( + repository: FakePasswordRepository, website: str, email: str, password: str +) -> None: + with pytest.raises(EmptyFieldError): + SavePasswordEntry(repository).execute(website, email, password) + assert repository.entries == {} + + +def test_find_returns_entry(repository: FakePasswordRepository) -> None: + SavePasswordEntry(repository).execute("example.com", "me@x.org", "pw") + + entry = FindPasswordEntry(repository).execute("example.com") + + assert entry.email == "me@x.org" + + +def test_find_raises_when_website_unknown( + repository: FakePasswordRepository, +) -> None: + with pytest.raises(EntryNotFoundError): + FindPasswordEntry(repository).execute("nope.com") + + +def test_find_raises_when_no_data_file() -> None: + with pytest.raises(DataFileNotFoundError): + FindPasswordEntry(FakePasswordRepository(has_file=False)).execute("a.com") + + +def test_find_rejects_empty_website(repository: FakePasswordRepository) -> None: + with pytest.raises(EmptyFieldError): + FindPasswordEntry(repository).execute("") + + +def test_generate_uses_given_random_source() -> None: + first = GeneratePassword(random.Random(1)).execute() + second = GeneratePassword(random.Random(1)).execute() + + assert first == second diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py new file mode 100644 index 0000000..901c0f5 --- /dev/null +++ b/tests/unit/conftest.py @@ -0,0 +1,30 @@ +"""Shared fakes of the ports for the unit tests.""" + +import pytest + +from password_manager.domain.errors import DataFileNotFoundError, EntryNotFoundError +from password_manager.domain.password_entry import PasswordEntry + + +class FakePasswordRepository: + """In-memory PasswordRepository; `has_file` False mimics a missing file.""" + + def __init__(self, has_file: bool = True) -> None: + self.entries: dict[str, PasswordEntry] = {} + self.has_file = has_file + + def add(self, entry: PasswordEntry) -> None: + self.entries[entry.website] = entry + self.has_file = True + + def find(self, website: str) -> PasswordEntry: + if not self.has_file: + raise DataFileNotFoundError + if website not in self.entries: + raise EntryNotFoundError(website) + return self.entries[website] + + +@pytest.fixture +def repository() -> FakePasswordRepository: + return FakePasswordRepository() diff --git a/tests/unit/domain/test_password_generator.py b/tests/unit/domain/test_password_generator.py new file mode 100644 index 0000000..4bb3a86 --- /dev/null +++ b/tests/unit/domain/test_password_generator.py @@ -0,0 +1,35 @@ +import random + +from password_manager import constants +from password_manager.domain.password_generator import generate_password + + +def _count(password: str, alphabet: str) -> int: + return sum(1 for char in password if char in alphabet) + + +def test_generate_password_length_within_bounds() -> None: + low = constants.MIN_LETTERS + constants.MIN_SYMBOLS + constants.MIN_DIGITS + high = constants.MAX_LETTERS + constants.MAX_SYMBOLS + constants.MAX_DIGITS + for seed in range(50): + assert low <= len(generate_password(random.Random(seed))) <= high + + +def test_generate_password_uses_all_three_classes_within_bounds() -> None: + for seed in range(50): + password = generate_password(random.Random(seed)) + letters = _count(password, constants.LETTERS) + symbols = _count(password, constants.SYMBOLS) + digits = _count(password, constants.DIGITS) + assert constants.MIN_LETTERS <= letters <= constants.MAX_LETTERS + assert constants.MIN_SYMBOLS <= symbols <= constants.MAX_SYMBOLS + assert constants.MIN_DIGITS <= digits <= constants.MAX_DIGITS + assert letters + symbols + digits == len(password) + + +def test_generate_password_is_reproducible_with_same_seed() -> None: + assert generate_password(random.Random(7)) == generate_password(random.Random(7)) + + +def test_generate_password_default_source_works() -> None: + assert generate_password() diff --git a/tests/unit/presentation/test_password_manager_view_model.py b/tests/unit/presentation/test_password_manager_view_model.py new file mode 100644 index 0000000..6c5cf79 --- /dev/null +++ b/tests/unit/presentation/test_password_manager_view_model.py @@ -0,0 +1,121 @@ +import random + +from conftest import FakePasswordRepository + +from password_manager import constants +from password_manager.application.use_cases import ( + FindPasswordEntry, + GeneratePassword, + SavePasswordEntry, +) +from password_manager.presentation.features.password_manager import ( + password_manager_view_model as view_model_module, +) + + +class FakeUi: + """Records clipboard writes and questions, answers with a fixed choice.""" + + def __init__(self, answer: bool = True) -> None: + self.answer = answer + self.clipboard: list[str] = [] + self.questions: list[str] = [] + + def copy(self, text: str) -> None: + self.clipboard.append(text) + + def confirm(self, message: str) -> bool: + self.questions.append(message) + return self.answer + + +def _make( + repository: FakePasswordRepository, ui: FakeUi +) -> view_model_module.PasswordManagerViewModel: + return view_model_module.PasswordManagerViewModel( + SavePasswordEntry(repository), + FindPasswordEntry(repository), + GeneratePassword(random.Random(3)), + ui.copy, + ui.confirm, + ) + + +def test_generate_password_copies_to_clipboard( + repository: FakePasswordRepository, +) -> None: + ui = FakeUi() + + password = _make(repository, ui).generate_password() + + assert ui.clipboard == [password] + + +def test_save_stores_and_clears_fields_after_confirmation( + repository: FakePasswordRepository, +) -> None: + ui = FakeUi(answer=True) + + result = _make(repository, ui).save("a.com", "me@x.org", "pw") + + assert result is not None + assert not result.is_error + assert result.clear_fields + assert "a.com" in repository.entries + assert "me@x.org" in ui.questions[0] + + +def test_save_does_nothing_when_user_cancels( + repository: FakePasswordRepository, +) -> None: + result = _make(repository, FakeUi(answer=False)).save("a.com", "e", "pw") + + assert result is None + assert repository.entries == {} + + +def test_save_refuses_empty_field_without_asking( + repository: FakePasswordRepository, +) -> None: + ui = FakeUi() + + result = _make(repository, ui).save("", "e", "pw") + + assert result is not None + assert result.is_error + assert result.message == constants.MESSAGE_EMPTY_FIELDS + assert not result.clear_fields + assert ui.questions == [] + + +def test_find_password_found(repository: FakePasswordRepository) -> None: + model = _make(repository, FakeUi()) + model.save("a.com", "me@x.org", "pw") + + result = model.find_password("a.com") + + assert not result.is_error + assert result.message == constants.MESSAGE_DETAILS.format( + email="me@x.org", password="pw" + ) + + +def test_find_password_website_not_present( + repository: FakePasswordRepository, +) -> None: + result = _make(repository, FakeUi()).find_password("b.com") + + assert result.is_error + assert result.message == constants.MESSAGE_NO_DETAILS.format(website="b.com") + + +def test_find_password_no_data_file() -> None: + model = _make(FakePasswordRepository(has_file=False), FakeUi()) + + assert model.find_password("a.com").message == constants.MESSAGE_NO_DATA_FILE + + +def test_find_password_empty_website(repository: FakePasswordRepository) -> None: + result = _make(repository, FakeUi()).find_password(" ") + + assert result.message == constants.MESSAGE_EMPTY_FIELDS