test: unit, integration and e2e tests
Task: MIL-002#6
This commit is contained in:
@@ -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
|
||||||
|
)
|
||||||
@@ -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")
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user