diff --git a/pyproject.toml b/pyproject.toml index 0a1b086..124a6f3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,36 +1,32 @@ - [build-system] - requires = ["hatchling"] - build-backend = "hatchling.build" +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" +[project] +name = "pilgrim" +version = "0.0.4" +authors = [ + { name="Gustavo Henrique Santos Souza de Miranda", email="gustavohssmiranda@gmail.com" } +] +description = "Pilgrim's Travel Log" +readme = "README.md" +requires-python = ">=3.10" +classifiers = [ + "Programming Language :: Python :: 3", + "License :: OSI Approved :: BSD License", + "Operating System :: OS Independent", +] +dependencies = [ + "sqlalchemy", + "textual", + "tomli", + "tomli_w", + "unidecode" +] - [project] - name = "Pilgrim" - version = "0.0.4" - authors = [ - { name="Gustavo Henrique Santos Souza de Miranda", email="gustavohssmiranda@gmail.com" } - ] - description = "Pilgrim's Travel Log" - readme = "README.md" - requires-python = ">=3.10" - classifiers = [ - "Programming Language :: Python :: 3", - "License :: OSI Approved :: BSD License", - "Operating System :: OS Independent", - ] - dependencies = [ - "sqlalchemy", - "textual", - "tomli", - "tomli_w" +[project.urls] +Homepage = "https://github.com/gmbrax/Pilgrim/" +Issues = "https://github.com/gmbrax/Pilgrim/issues" - - ] - [template.plugins.default] - src-layout = true - [project.urls] - Homepage = "https://github.com/gmbrax/Pilgrim/" - Issues = "https://github.com/gmbrax/Pilgrim/issues" - [tool.hatch.build.targets.wheel] - packages = ["src/pilgrim"] - [project.scripts] - pilgrim = "pilgrim:main" +[project.scripts] +pilgrim = "pilgrim.command:main" \ No newline at end of file diff --git a/src/pilgrim/database.py b/src/pilgrim/database.py index 8d472af..44ca8e9 100644 --- a/src/pilgrim/database.py +++ b/src/pilgrim/database.py @@ -1,5 +1,5 @@ from sqlalchemy import create_engine -from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import declarative_base from sqlalchemy.orm import sessionmaker from pathlib import Path import os diff --git a/src/pilgrim/models/entry.py b/src/pilgrim/models/entry.py index dac87b0..7835067 100644 --- a/src/pilgrim/models/entry.py +++ b/src/pilgrim/models/entry.py @@ -4,15 +4,16 @@ from pilgrim.models.photo_in_entry import photo_entry_association from sqlalchemy import Column, Integer, String, ForeignKey, DateTime from sqlalchemy.orm import relationship -from ..database import Base +from pilgrim.database import Base + class Entry(Base): __tablename__ = "entries" id = Column(Integer, primary_key=True) - title = Column(String) + title = Column(String,nullable=False) text = Column(String) - date = Column(DateTime) + date = Column(DateTime,nullable=False) photos = relationship( "Photo", secondary=photo_entry_association, diff --git a/src/pilgrim/models/photo.py b/src/pilgrim/models/photo.py index 3a1ac9b..88fc5a2 100644 --- a/src/pilgrim/models/photo.py +++ b/src/pilgrim/models/photo.py @@ -7,7 +7,8 @@ from sqlalchemy.orm import relationship from sqlalchemy.sql.schema import Index from pilgrim.models.photo_in_entry import photo_entry_association -from ..database import Base +from pilgrim.database import Base + class Photo(Base): @@ -24,7 +25,8 @@ class Photo(Base): back_populates="photos" ) - fk_travel_diary_id = Column(Integer, ForeignKey("travel_diaries.id"),nullable=False) + fk_travel_diary_id = Column(Integer, ForeignKey("travel_diaries.id"), nullable=False) + travel_diary = relationship("TravelDiary", back_populates="photos") __table_args__ = ( Index('idx_photo_hash_diary', 'hash', 'fk_travel_diary_id'), ) diff --git a/src/pilgrim/models/photo_in_entry.py b/src/pilgrim/models/photo_in_entry.py index 76f452e..8e9d677 100644 --- a/src/pilgrim/models/photo_in_entry.py +++ b/src/pilgrim/models/photo_in_entry.py @@ -1,6 +1,6 @@ from sqlalchemy import Table, Column, Integer, ForeignKey -from ..database import Base +from pilgrim.database import Base photo_entry_association = Table('photo_entry_association', Base.metadata, Column('id', Integer, primary_key=True, autoincrement=True), diff --git a/src/pilgrim/models/travel_diary.py b/src/pilgrim/models/travel_diary.py index 1164bc4..90a89b5 100644 --- a/src/pilgrim/models/travel_diary.py +++ b/src/pilgrim/models/travel_diary.py @@ -3,15 +3,17 @@ from typing import Any from sqlalchemy import Column, Integer, String, UniqueConstraint from sqlalchemy.orm import relationship -from .. import database +from pilgrim.database import Base -class TravelDiary(database.Base): + +class TravelDiary(Base): __tablename__ = "travel_diaries" id = Column(Integer, primary_key=True) name = Column(String, nullable=False) directory_name = Column(String, nullable=False, unique=True) entries = relationship("Entry", back_populates="travel_diary", cascade="all, delete-orphan") + photos = relationship("Photo", back_populates="travel_diary", cascade="all, delete-orphan") __table_args__ = ( UniqueConstraint('directory_name', name='uq_travel_diary_directory_name'), diff --git a/src/pilgrim/service/entry_service.py b/src/pilgrim/service/entry_service.py index 040f5a8..5826c31 100644 --- a/src/pilgrim/service/entry_service.py +++ b/src/pilgrim/service/entry_service.py @@ -1,9 +1,9 @@ from datetime import datetime from typing import List -from ..models.entry import Entry -from ..models.travel_diary import TravelDiary -from ..models.photo import Photo # ✨ Importe o modelo Photo +from pilgrim.models.entry import Entry +from pilgrim.models.travel_diary import TravelDiary +from pilgrim.models.photo import Photo # ✨ Importe o modelo Photo class EntryService: diff --git a/src/pilgrim/service/travel_diary_service.py b/src/pilgrim/service/travel_diary_service.py index 34136e7..8932b8c 100644 --- a/src/pilgrim/service/travel_diary_service.py +++ b/src/pilgrim/service/travel_diary_service.py @@ -6,8 +6,8 @@ from pathlib import Path from pilgrim.utils import DirectoryManager from sqlalchemy.exc import IntegrityError -from ..models.travel_diary import TravelDiary - +from pilgrim.models.travel_diary import TravelDiary +from unidecode import unidecode class TravelDiaryService: def __init__(self, session): @@ -20,8 +20,10 @@ class TravelDiaryService: - Replaces spaces with underscores - Ensures name is unique by adding a suffix if needed """ + transliterated_name = unidecode(name) + # Remove special characters and replace spaces - safe_name = re.sub(r'[^\w\s-]', '', name) + safe_name = re.sub(r'[^\w\s-]', '', transliterated_name) safe_name = safe_name.strip().replace(' ', '_').lower() # Ensure we have a valid name diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..59d0e26 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,64 @@ +import pytest +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +from pilgrim.database import Base +from pilgrim.models.travel_diary import TravelDiary +from pilgrim.models.photo import Photo + +# Todos os imports necessários para as fixtures devem estar aqui +# ... + +@pytest.fixture(scope="function") +def db_session(): + """Esta fixture agora está disponível para TODOS os testes.""" + engine = create_engine("sqlite:///:memory:") + Base.metadata.create_all(engine) + Session = sessionmaker(bind=engine) + session = Session() + yield session + session.close() + Base.metadata.drop_all(engine) + +@pytest.fixture +def populated_db_session(db_session): + """Esta também fica disponível para todos.""" + travel_diary = TravelDiary(name="My Travel Diary", directory_name="viagem-teste") + db_session.add(travel_diary) + db_session.commit() + return db_session + +@pytest.fixture +def session_with_one_diary(db_session): + diary = TravelDiary(name="Diário de Teste", directory_name="diario_de_teste") + db_session.add(diary) + db_session.commit() + db_session.refresh(diary) + return db_session, diary + + +@pytest.fixture +def session_with_photos(session_with_one_diary): + session, diary = session_with_one_diary + + # Usamos a mesma raiz de diretório que o mock do teste espera + diaries_root = "/fake/diaries_root" + + photo1 = Photo( + # CORREÇÃO: O caminho agora inclui a raiz e a subpasta 'images' + filepath=f"{diaries_root}/{diary.directory_name}/images/p1.jpg", + name="Foto 1", + photo_hash="hash1", + fk_travel_diary_id=diary.id + ) + photo2 = Photo( + filepath=f"{diaries_root}/{diary.directory_name}/images/p2.jpg", + name="Foto 2", + photo_hash="hash2", + fk_travel_diary_id=diary.id + ) + + session.add_all([photo1, photo2]) + session.commit() + + return session, [photo1, photo2] \ No newline at end of file diff --git a/tests/service/test_entry_service.py b/tests/service/test_entry_service.py new file mode 100644 index 0000000..6cf267c --- /dev/null +++ b/tests/service/test_entry_service.py @@ -0,0 +1,269 @@ +from re import search + +import pytest +from datetime import datetime +from unittest.mock import Mock +from sqlalchemy import create_engine +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import sessionmaker + +from pilgrim.database import Base +from pilgrim.models.travel_diary import TravelDiary +from pilgrim.models.entry import Entry +from pilgrim.models.photo import Photo + +from pilgrim.service.entry_service import EntryService + + + +@pytest.fixture +def session_with_an_entry(populated_db_session): + session = populated_db_session + initial_entry = Entry( + title="Título Original", + text="Texto original.", + date=datetime(2025, 1, 1), + travel_diary_id=1 + ) + session.add(initial_entry) + session.commit() + return session, initial_entry.id + +@pytest.fixture +def session_with_multiple_entries(populated_db_session): + """Fixture que cria um diário e duas entradas para ele.""" + session = populated_db_session + + entry1 = Entry(title="Entrada 1", text="Texto 1", date=datetime(2025, 1, 1), travel_diary_id=1) + entry2 = Entry(title="Entrada 2", text="Texto 2", date=datetime(2025, 1, 2), travel_diary_id=1) + + session.add_all([entry1, entry2]) + session.commit() + + return session + +def test_create_entry_successfully(populated_db_session): + session = populated_db_session + service = EntryService(session) + diary_id = 1 # Sabemos que o ID é 1 por causa da nossa fixture + title = "Primeiro Dia na Praia" + text = "O dia foi ensolarado e o mar estava ótimo." + date = datetime(2025, 7, 20) + photos = [Photo(filepath="/path/to/photo1.jpg",name="Photo 1",photo_hash="hash_12345678",fk_travel_diary_id=diary_id), Photo(filepath="/path/to/photo2.jpg",name="Photo 1",photo_hash="hash_87654321",fk_travel_diary_id=diary_id)] + created_entry = service.create( + travel_diary_id=diary_id, + title=title, + text=text, + date=date, + photos=photos + ) + assert created_entry is not None + assert created_entry.id is not None # Garante que foi salvo no BD e tem um ID + assert created_entry.title == title + assert created_entry.text == text + assert len(created_entry.photos) == 2 + assert created_entry.photos[0].filepath == "/path/to/photo1.jpg" + + entry_in_db = session.query(Entry).filter_by(id=created_entry.id).one() + assert entry_in_db.title == "Primeiro Dia na Praia" + +def test_create_entry_fails_when_diary_id_is_invalid(db_session): + session = db_session + service = EntryService(session) + invalid_id = 666 + + result = service.create( + travel_diary_id=invalid_id, + title="Título de Teste", + text="Texto de Teste", + date=datetime(2025, 7, 20), + photos=[] + ) + + assert result is None + +def test_create_entry_successfully_without_photo(populated_db_session): + session = populated_db_session + service = EntryService(session) + diary_id = 1 # Sabemos que o ID é 1 por causa da nossa fixture + title = "Primeiro Dia na Praia" + text = "O dia foi ensolarado e o mar estava ótimo." + date = datetime(2025, 7, 20) + photos = [] + created_entry = service.create( + travel_diary_id=diary_id, + title=title, + text=text, + date=date, + photos=photos + ) + assert created_entry is not None + assert created_entry.id is not None # Garante que foi salvo no BD e tem um ID + assert created_entry.title == title + assert created_entry.text == text + assert len(created_entry.photos) == 0 + entry_in_db = session.query(Entry).filter_by(id=created_entry.id).one() + assert entry_in_db.title == "Primeiro Dia na Praia" + +def test_create_entry_fails_with_null_title(populated_db_session): + session = populated_db_session + service = EntryService(session) + diary_id = 1 + with pytest.raises(IntegrityError): + service.create( + travel_diary_id=diary_id, + title=None, + text="Um texto qualquer.", + date=datetime.now(), + photos=[] + ) + +def test_create_entry_fails_with_null_date(populated_db_session): + session = populated_db_session + service = EntryService(session) + diary_id = 1 + with pytest.raises(IntegrityError): + service.create( + travel_diary_id=diary_id, + title="Sabado de sol", + text="Um texto qualquer.", + date=None, + photos=[] + ) + +def test_create_entry_fails_with_null_diary_id(populated_db_session): + session = populated_db_session + service = EntryService(session) + diary_id = 1 + result = service.create( + travel_diary_id=None, + title="Sabado de sol", + text="Um texto qualquer.", + date=datetime.now(), + photos=[] + ) + assert result is None +def test_ready_by_id_successfully(session_with_an_entry): + session,_ = session_with_an_entry + service = EntryService(session) + search_id = 1 + result = service.read_by_id(search_id) + assert result is not None +def test_ready_by_id_fails_when_id_is_invalid(db_session): + session = db_session + service = EntryService(session) + invalid_id = 666 + result = service.read_by_id(invalid_id) + assert result is None + +def test_read_all_returns_all_entries(session_with_multiple_entries): + session = session_with_multiple_entries + service = EntryService(session) + all_entries = service.read_all() + assert isinstance(all_entries, list) + assert len(all_entries) == 2 + assert all_entries[0].title == "Entrada 1" + assert all_entries[1].title == "Entrada 2" + +def test_read_all_returns_empty_list_on_empty_db(db_session): + session = db_session + service = EntryService(session) + all_entries = service.read_all() + assert isinstance(all_entries, list) + assert len(all_entries) == 0 + +def test_update_entry_successfully(session_with_an_entry): + session, entry_id = session_with_an_entry + service = EntryService(session) + entry_src = session.query(Entry).filter_by(id=entry_id).one() + new_date = datetime(2025, 1, 2) + entry_dst = Entry( + title="Título Atualizado", + text="Texto atualizado.", + date=new_date, + travel_diary_id=1, # Mantemos o mesmo travel_diary_id + photos=[] + ) + updated_entry = service.update(entry_src, entry_dst) + assert updated_entry is not None + assert updated_entry.id == entry_id + assert updated_entry.title == "Título Atualizado" + assert updated_entry.text == "Texto atualizado." + entry_in_db = session.query(Entry).filter_by(id=entry_id).one() + assert entry_in_db.title == "Título Atualizado" + +def test_update_entry_fails_if_entry_does_not_exist(db_session): + service = EntryService(db_session) + non_existent_entry = Entry( + title="dummy", + text="dummy", + date=datetime.now(), + travel_diary_id=1) + non_existent_entry.id = 999 + entry_with_new_data = Entry(title="Novo Título", text="Novo Texto", date=datetime.now(), travel_diary_id=1) + result = service.update(non_existent_entry, entry_with_new_data) + assert result is None + +def test_update_fails_with_null_title(session_with_an_entry): + session, entry_id = session_with_an_entry + service = EntryService(session) + entry_src = session.query(Entry).filter_by(id=entry_id).one() + entry_dst = Entry( + title=None, + text="Texto atualizado.", + date=datetime.now(), + travel_diary_id=1, + photos=[] + ) + with pytest.raises(IntegrityError): + service.update(entry_src, entry_dst) + +def test_update_fails_with_null_date(session_with_an_entry): + session, entry_id = session_with_an_entry + service = EntryService(session) + entry_src = session.query(Entry).filter_by(id=entry_id).one() + entry_dst = Entry( + title=entry_src.title, + text="Texto atualizado.", + date=None, + travel_diary_id=1, + photos=[] + ) + with pytest.raises(IntegrityError): + service.update(entry_src, entry_dst) + +def test_update_fails_with_null_diary_id(session_with_an_entry): + session, entry_id = session_with_an_entry + service = EntryService(session) + entry_src = session.query(Entry).filter_by(id=entry_id).one() + entry_dst = Entry( + title=entry_src.title, + text="Texto atualizado.", + date=datetime.now(), + travel_diary_id=None, + photos=[] + ) + with pytest.raises(IntegrityError): + service.update(entry_src, entry_dst) + +def test_delete_successfully_removes_entry(session_with_an_entry): + session, entry_id = session_with_an_entry + service = EntryService(session) + entry_to_delete = service.read_by_id(entry_id) + assert entry_to_delete is not None + deleted_entry = service.delete(entry_to_delete) + assert deleted_entry is not None + assert deleted_entry.id == entry_id + entry_in_db = service.read_by_id(entry_id) + assert entry_in_db is None + +def test_delete_returns_none_if_entry_does_not_exist(db_session): + service = EntryService(db_session) + non_existent_entry = Entry( + title="dummy", + text="dummy", + date=datetime.now(), + travel_diary_id=1) + non_existent_entry.id = 999 + result = service.delete(non_existent_entry) + assert result is None diff --git a/tests/service/test_photo_service.py b/tests/service/test_photo_service.py new file mode 100644 index 0000000..62009f7 --- /dev/null +++ b/tests/service/test_photo_service.py @@ -0,0 +1,249 @@ +import pytest +from pathlib import Path + +from pilgrim import TravelDiary +from pilgrim.service.photo_service import PhotoService +import hashlib +from unittest.mock import patch +from pilgrim.models.photo import Photo +from pilgrim.utils import DirectoryManager + + +@patch.object(PhotoService, '_copy_photo_to_diary') +@patch.object(PhotoService, 'hash_file', return_value="fake_hash_123") +def test_create_photo_successfully(mock_hash, mock_copy, session_with_one_diary): + session, diary = session_with_one_diary + service = PhotoService(session) + fake_source_path = Path("/path/original/imagem.jpg") + fake_copied_path = Path(f"~/.pilgrim/diaries/{diary.directory_name}/images/imagem.jpg") + mock_copy.return_value = fake_copied_path + new_photo = service.create( + filepath=fake_source_path, + name="Foto da Praia", + travel_diary_id=diary.id, + caption="Pôr do sol") + mock_hash.assert_called_once_with(fake_source_path) + mock_copy.assert_called_once_with(fake_source_path, diary) + assert new_photo is not None + assert new_photo.name == "Foto da Praia" + assert new_photo.photo_hash == "fake_hash_123" + assert new_photo.filepath == str(fake_copied_path) + +def test_hash_file_generates_correct_hash(tmp_path: Path): + original_content_bytes = b"um conteudo de teste para o hash" + file_on_disk = tmp_path / "test.jpg" + file_on_disk.write_bytes(original_content_bytes) + hash_from_file = PhotoService.hash_file(file_on_disk) + expected_hash_func = hashlib.new('sha3_384') + expected_hash_func.update(original_content_bytes) + hash_from_memory = expected_hash_func.hexdigest() + assert hash_from_file == hash_from_memory + +@patch.object(PhotoService, '_copy_photo_to_diary') +@patch.object(PhotoService, 'hash_file', return_value="hash_ja_existente") +def test_create_photo_returns_none_if_hash_exists(mock_hash, mock_copy, session_with_one_diary): + session, diary = session_with_one_diary + existing_photo = Photo( + filepath="/path/existente.jpg", name="Foto Antiga", + photo_hash="hash_ja_existente", fk_travel_diary_id=diary.id + ) + session.add(existing_photo) + session.commit() + + service = PhotoService(session) + new_photo = service.create( + filepath=Path("/path/novo/arquivo.jpg"), + name="Foto Nova", + travel_diary_id=diary.id + ) + assert new_photo is None + mock_copy.assert_not_called() + +def test_read_by_id_successfully(session_with_photos): + session, photos = session_with_photos + service = PhotoService(session) + photo_to_find_id = photos[0].id + found_photo = service.read_by_id(photo_to_find_id) + assert found_photo is not None + assert found_photo.id == photo_to_find_id + assert found_photo.name == "Foto 1" + +def test_read_by_id_returns_none_for_invalid_id(db_session): + service = PhotoService(db_session) + result = service.read_by_id(999) + assert result is None + +def test_read_all_returns_all_photos(session_with_photos): + session, _ = session_with_photos + service = PhotoService(session) + all_photos = service.read_all() + + assert isinstance(all_photos, list) + assert len(all_photos) == 2 + assert all_photos[0].name == "Foto 1" + assert all_photos[1].name == "Foto 2" + +def test_read_all_returns_empty_list_for_empty_db(db_session): + service = PhotoService(db_session) + all_photos = service.read_all() + assert isinstance(all_photos, list) + assert len(all_photos) == 0 + +def test_check_photo_by_hash_finds_existing_photo(session_with_photos): + session, photos = session_with_photos + service = PhotoService(session) + existing_photo = photos[0] + hash_to_find = existing_photo.photo_hash # "hash1" + diary_id = existing_photo.fk_travel_diary_id # 1 + found_photo = service.check_photo_by_hash(hash_to_find, diary_id) + assert found_photo is not None + assert found_photo.id == existing_photo.id + assert found_photo.photo_hash == hash_to_find + +def test_check_photo_by_hash_returns_none_when_not_found(session_with_photos): + session, photos = session_with_photos + service = PhotoService(session) + existing_hash = photos[0].photo_hash # "hash1" + existing_diary_id = photos[0].fk_travel_diary_id # 1 + result1 = service.check_photo_by_hash("hash_inexistente", existing_diary_id) + assert result1 is None + invalid_diary_id = 999 + result2 = service.check_photo_by_hash(existing_hash, invalid_diary_id) + assert result2 is None + +def test_update_photo_metadata_successfully(session_with_photos): + session, photos = session_with_photos + service = PhotoService(session) + photo_to_update = photos[0] + photo_with_new_data = Photo( + filepath=photo_to_update.filepath, + name="Novo Nome da Foto", + caption="Nova legenda para a foto.", + photo_hash=photo_to_update.photo_hash, # O hash não muda + addition_date=photo_to_update.addition_date, + fk_travel_diary_id=photo_to_update.fk_travel_diary_id + ) + updated_photo = service.update(photo_to_update, photo_with_new_data) + assert updated_photo is not None + assert updated_photo.name == "Novo Nome da Foto" + assert updated_photo.caption == "Nova legenda para a foto." + assert updated_photo.photo_hash == photo_to_update.photo_hash + photo_in_db = session.query(Photo).get(photo_to_update.id) + assert photo_in_db.name == "Novo Nome da Foto" + + +@patch.object(PhotoService, 'hash_file') +@patch('pathlib.Path.unlink') +@patch('pathlib.Path.exists') +@patch.object(PhotoService, '_copy_photo_to_diary') +@patch.object(DirectoryManager, 'get_diaries_root', return_value="/fake/diaries_root") +def test_update_photo_with_new_file_successfully( + mock_get_root, mock_copy, mock_exists, mock_unlink, mock_hash, session_with_photos +): + session, photos = session_with_photos + service = PhotoService(session) + photo_to_update = photos[0] + new_source_path = Path("/path/para/nova_imagem.jpg") + new_copied_path = Path(f"/fake/diaries_root/{photo_to_update.travel_diary.directory_name}/images/nova_imagem.jpg") + + mock_copy.return_value = new_copied_path + mock_exists.return_value = True + mock_hash.return_value = "novo_hash_calculado" + photo_with_new_file = Photo( + filepath=new_source_path, + name=photo_to_update.name, + photo_hash="hash_antigo", + fk_travel_diary_id=photo_to_update.fk_travel_diary_id + ) + updated_photo = service.update(photo_to_update, photo_with_new_file) + mock_copy.assert_called_once_with(new_source_path, photo_to_update.travel_diary) + mock_unlink.assert_called_once() + mock_hash.assert_called_once_with(new_copied_path) + assert updated_photo.filepath == str(new_copied_path) + assert updated_photo.photo_hash == "novo_hash_calculado" + +def test_update_photo_returns_none_if_photo_does_not_exist(db_session): + service = PhotoService(db_session) + non_existent_photo_src = Photo( + filepath="/fake/path.jpg", name="dummy", + photo_hash="dummy", fk_travel_diary_id=1 + ) + non_existent_photo_src.id = 999 + photo_with_new_data = Photo( + filepath="/fake/new.jpg", name="new dummy", + photo_hash="new_dummy", fk_travel_diary_id=1 + ) + result = service.update(non_existent_photo_src, photo_with_new_data) + assert result is None + + +@patch.object(PhotoService, 'hash_file') +@patch('pathlib.Path.unlink') +@patch('pathlib.Path.exists') +@patch.object(PhotoService, '_copy_photo_to_diary') +@patch.object(DirectoryManager, 'get_diaries_root', return_value="/fake/diaries_root") +def test_update_photo_with_new_file_successfully( + mock_get_root, mock_copy, mock_exists, mock_unlink, mock_hash, session_with_photos +): + session, photos = session_with_photos + service = PhotoService(session) + photo_to_update = photos[0] + new_source_path = Path("/path/para/nova_imagem.jpg") + new_copied_path = Path(f"/fake/diaries_root/{photo_to_update.travel_diary.directory_name}/images/nova_imagem.jpg") + mock_copy.return_value = new_copied_path + mock_exists.return_value = True + mock_hash.return_value = "novo_hash_calculado" + photo_with_new_file = Photo( + filepath=new_source_path, name=photo_to_update.name, + photo_hash="hash_antigo", fk_travel_diary_id=photo_to_update.fk_travel_diary_id + ) + updated_photo = service.update(photo_to_update, photo_with_new_file) + mock_copy.assert_called_once_with(new_source_path, photo_to_update.travel_diary) + mock_unlink.assert_called_once() + mock_hash.assert_called_once_with(new_copied_path) + + assert updated_photo.filepath == str(new_copied_path) + assert updated_photo.photo_hash == "novo_hash_calculado" + +@patch.object(DirectoryManager, 'get_diary_images_directory') +def test_copy_photo_to_diary_handles_name_collision_with_patch(mock_get_images_dir, db_session, tmp_path: Path): + images_dir = tmp_path / "images" + images_dir.mkdir() + mock_get_images_dir.return_value = images_dir + source_file = tmp_path / "foto.jpg" + source_file.touch() + (images_dir / "foto.jpg").touch() + service = PhotoService(db_session) + fake_diary = TravelDiary(name="test",directory_name="fake_diary") + copied_path = service._copy_photo_to_diary(source_file, fake_diary) + assert copied_path.name == "foto_1.jpg" + assert copied_path.exists() + +@patch('pathlib.Path.unlink') +@patch('pathlib.Path.exists') +@patch.object(DirectoryManager, 'get_diaries_root', return_value="/fake/diaries_root") +def test_delete_photo_successfully(mock_get_root, mock_exists, mock_unlink, session_with_photos): + session, photos = session_with_photos + service = PhotoService(session) + photo_to_delete = photos[0] + photo_id = photo_to_delete.id + mock_exists.return_value = True + deleted_photo_data = service.delete(photo_to_delete) + mock_unlink.assert_called_once() + assert deleted_photo_data is not None + assert deleted_photo_data.id == photo_id + photo_in_db = service.read_by_id(photo_id) + assert photo_in_db is None + +@patch('pathlib.Path.unlink') +def test_delete_returns_none_for_non_existent_photo(mock_unlink, db_session): + service = PhotoService(db_session) + non_existent_photo = Photo( + filepath="/fake/path.jpg", name="dummy", + photo_hash="dummy_hash", fk_travel_diary_id=1 + ) + non_existent_photo.id = 999 + result = service.delete(non_existent_photo) + assert result is None + mock_unlink.assert_not_called() + diff --git a/tests/service/test_service_manager.py b/tests/service/test_service_manager.py new file mode 100644 index 0000000..ed66e64 --- /dev/null +++ b/tests/service/test_service_manager.py @@ -0,0 +1,33 @@ +from pilgrim.service.servicemanager import ServiceManager +from unittest.mock import patch, MagicMock + +def test_initial_state_is_none(): + manager = ServiceManager() + assert manager.get_session() is None + assert manager.get_entry_service() is None + assert manager.get_photo_service() is None + assert manager.get_travel_diary_service() is None + +@patch('pilgrim.service.servicemanager.TravelDiaryService') +@patch('pilgrim.service.servicemanager.PhotoService') +@patch('pilgrim.service.servicemanager.EntryService') +def test_get_services_instantiates_with_correct_session( + mock_entry_service, mock_photo_service, mock_travel_diary_service +): + manager = ServiceManager() + mock_session = MagicMock() + manager.set_session(mock_session) + + entry_service_instance = manager.get_entry_service() + photo_service_instance = manager.get_photo_service() + travel_diary_service_instance = manager.get_travel_diary_service() + + mock_entry_service.assert_called_once() + mock_photo_service.assert_called_once() + mock_travel_diary_service.assert_called_once() + mock_entry_service.assert_called_once_with(mock_session) + mock_photo_service.assert_called_once_with(mock_session) + mock_travel_diary_service.assert_called_once_with(mock_session) + assert entry_service_instance == mock_entry_service.return_value + assert photo_service_instance == mock_photo_service.return_value + assert travel_diary_service_instance == mock_travel_diary_service.return_value \ No newline at end of file diff --git a/tests/service/test_travel_diary_service.py b/tests/service/test_travel_diary_service.py new file mode 100644 index 0000000..efab0f9 --- /dev/null +++ b/tests/service/test_travel_diary_service.py @@ -0,0 +1,143 @@ +from unittest.mock import patch, MagicMock +from pathlib import Path + +import pytest + +from pilgrim import TravelDiary +from pilgrim.service.travel_diary_service import TravelDiaryService + +@patch.object(TravelDiaryService, '_ensure_diary_directory') +@pytest.mark.asyncio # Marca o teste para rodar código assíncrono +async def test_create_diary_successfully(mock_ensure_dir, db_session): + service = TravelDiaryService(db_session) + new_diary = await service.async_create("Viagem para a Serra") + assert new_diary is not None + assert new_diary.id is not None + assert new_diary.name == "Viagem para a Serra" + assert new_diary.directory_name == "viagem_para_a_serra" + +@patch.object(TravelDiaryService, '_ensure_diary_directory') +@patch.object(TravelDiaryService, '_sanitize_directory_name', return_value="nome_existente") +@pytest.mark.asyncio +async def test_create_diary_handles_integrity_error(mock_sanitize, mock_ensure_dir, db_session): + existing_diary = TravelDiary(name="Diário Antigo", directory_name="nome_existente") + db_session.add(existing_diary) + db_session.commit() + service = TravelDiaryService(db_session) + with pytest.raises(ValueError, match="Could not create diary"): + await service.async_create("Qualquer Nome Novo") + mock_ensure_dir.assert_not_called() + +@patch.object(TravelDiaryService, '_ensure_diary_directory') +def test_read_by_id_successfully(mock_ensure_dir, session_with_one_diary): + session, diary_to_find = session_with_one_diary + service = TravelDiaryService(session) + found_diary = service.read_by_id(diary_to_find.id) + assert found_diary is not None + assert found_diary.id == diary_to_find.id + assert found_diary.name == "Diário de Teste" + mock_ensure_dir.assert_called_once_with(found_diary) + +@patch.object(TravelDiaryService, '_ensure_diary_directory') +def test_read_by_id_returns_none_for_invalid_id(mock_ensure_dir, db_session): + service = TravelDiaryService(db_session) + result = service.read_by_id(999) + assert result is None + mock_ensure_dir.assert_not_called() + +@patch.object(TravelDiaryService, '_ensure_diary_directory') +def test_read_all_returns_all_diaries(mock_ensure_dir, db_session): + d1 = TravelDiary(name="Diário 1", directory_name="d1") + d2 = TravelDiary(name="Diário 2", directory_name="d2") + db_session.add_all([d1, d2]) + db_session.commit() + service = TravelDiaryService(db_session) + diaries = service.read_all() + assert isinstance(diaries, list) + assert len(diaries) == 2 + assert mock_ensure_dir.call_count == 2 + +@patch.object(TravelDiaryService, '_ensure_diary_directory') +def test_read_all_returns_empty_list_for_empty_db(mock_ensure_dir, db_session): + service = TravelDiaryService(db_session) + diaries = service.read_all() + assert isinstance(diaries, list) + assert len(diaries) == 0 + mock_ensure_dir.assert_not_called() + +@patch.object(TravelDiaryService, '_ensure_diary_directory') +@patch('pathlib.Path.rename') +@patch.object(TravelDiaryService, '_get_diary_directory') +def test_update_diary_successfully(mock_get_dir, mock_path_rename, mock_ensure, session_with_one_diary): + session, diary_to_update = session_with_one_diary + service = TravelDiaryService(session) + old_path = MagicMock(spec=Path) # Um mock que se parece com um objeto Path + old_path.exists.return_value = True # Dizemos que o diretório antigo "existe" + new_path = Path("/fake/path/diario_atualizado") + mock_get_dir.side_effect = [old_path, new_path] + updated_diary = service.update(diary_to_update.id, "Diário Atualizado") + assert updated_diary is not None + assert updated_diary.name == "Diário Atualizado" + assert updated_diary.directory_name == "diario_atualizado" + old_path.rename.assert_called_once_with(new_path) + +def test_update_returns_none_for_invalid_id(db_session): + service = TravelDiaryService(db_session) + result = service.update(travel_diary_id=999, name="Nome Novo") + assert result is None + +@patch.object(TravelDiaryService, '_cleanup_diary_directory') +def test_delete_diary_successfully(mock_cleanup, session_with_one_diary): + session, diary_to_delete = session_with_one_diary + service = TravelDiaryService(session) + result = service.delete(diary_to_delete) + assert result is not None + assert result.id == diary_to_delete.id + mock_cleanup.assert_called_once_with(diary_to_delete) + diary_in_db = service.read_by_id(diary_to_delete.id) + assert diary_in_db is None + +@patch.object(TravelDiaryService, '_cleanup_diary_directory') +def test_delete_returns_none_for_non_existent_diary(mock_cleanup, db_session): + service = TravelDiaryService(db_session) + non_existent_diary = TravelDiary(name="dummy", directory_name="dummy") + non_existent_diary.id = 999 + result = service.delete(non_existent_diary) + assert result is None + mock_cleanup.assert_not_called() + +@patch.object(TravelDiaryService, '_sanitize_directory_name') +def test_update_raises_value_error_on_name_collision(mock_sanitize, db_session): + + d1 = TravelDiary(name="Diário A", directory_name="diario_a") + d2 = TravelDiary(name="Diário B", directory_name="diario_b") + db_session.add_all([d1, d2]) + db_session.commit() + db_session.refresh(d1) + mock_sanitize.return_value = "diario_b" + service = TravelDiaryService(db_session) + with pytest.raises(ValueError, match="Could not update diary"): + service.update(d1.id, "Diário B") + +def test_sanitize_directory_name_formats_string_correctly(db_session): + service = TravelDiaryService(db_session) + name1 = "Minha Primeira Viagem" + assert service._sanitize_directory_name(name1) == "minha_primeira_viagem" + name2 = "Viagem para o #Rio de Janeiro! @2025" + assert service._sanitize_directory_name(name2) == "viagem_para_o_rio_de_janeiro_2025" + name3 = " Mochilão na Europa " + assert service._sanitize_directory_name(name3) == "mochilao_na_europa" + +def test_sanitize_directory_name_handles_uniqueness(db_session): + existing_diary = TravelDiary(name="Viagem para a Praia", directory_name="viagem_para_a_praia") + db_session.add(existing_diary) + db_session.commit() + service = TravelDiaryService(db_session) + new_sanitized_name = service._sanitize_directory_name("Viagem para a Praia") + assert new_sanitized_name == "viagem_para_a_praia_1" + another_diary = TravelDiary(name="Outra Viagem", directory_name="viagem_para_a_praia_1") + db_session.add(another_diary) + db_session.commit() + + third_sanitized_name = service._sanitize_directory_name("Viagem para a Praia") + assert third_sanitized_name == "viagem_para_a_praia_2" \ No newline at end of file diff --git a/tests/test_application.py b/tests/test_application.py new file mode 100644 index 0000000..c4f1ae0 --- /dev/null +++ b/tests/test_application.py @@ -0,0 +1,56 @@ +from unittest.mock import patch, MagicMock +from pilgrim.application import Application + +@patch('pilgrim.application.UIApp') +@patch('pilgrim.application.ServiceManager') +@patch('pilgrim.application.Database') +@patch('pilgrim.application.ConfigManager') +def test_application_initialization_wires_dependencies( + MockConfigManager, MockDatabase, MockServiceManager, MockUIApp +): + mock_config_instance = MockConfigManager.return_value + mock_db_instance = MockDatabase.return_value + mock_session_instance = mock_db_instance.session.return_value + mock_service_manager_instance = MockServiceManager.return_value + app = Application() + MockConfigManager.assert_called_once() + MockDatabase.assert_called_once_with(mock_config_instance) + MockServiceManager.assert_called_once() + MockUIApp.assert_called_once_with(mock_service_manager_instance, mock_config_instance) + mock_config_instance.read_config.assert_called_once() + mock_db_instance.session.assert_called_once() + mock_service_manager_instance.set_session.assert_called_once_with(mock_session_instance) + +@patch('pilgrim.application.UIApp') +@patch('pilgrim.application.ServiceManager') +@patch('pilgrim.application.Database') +@patch('pilgrim.application.ConfigManager') +def test_application_run_calls_methods( + MockConfigManager, MockDatabase, MockServiceManager, MockUIApp +): + app = Application() + mock_db_instance = app.database + mock_ui_instance = app.ui + app.run() + mock_db_instance.create.assert_called_once() + mock_ui_instance.run.assert_called_once() + +@patch('pilgrim.application.UIApp') +@patch('pilgrim.application.ServiceManager') +@patch('pilgrim.application.Database') +@patch('pilgrim.application.ConfigManager') +def test_get_service_manager_creates_and_configures_new_instance( + MockConfigManager, MockDatabase, MockServiceManager, MockUIApp +): + app = Application() + mock_db_instance = app.database + fake_session = MagicMock() + mock_db_instance.session.return_value = fake_session + mock_db_instance.reset_mock() + MockServiceManager.reset_mock() + returned_manager = app.get_service_manager() + mock_db_instance.session.assert_called_once() + MockServiceManager.assert_called_once() + returned_manager.set_session.assert_called_once_with(fake_session) + + assert returned_manager is MockServiceManager.return_value \ No newline at end of file diff --git a/tests/test_command.py b/tests/test_command.py new file mode 100644 index 0000000..c95a85a --- /dev/null +++ b/tests/test_command.py @@ -0,0 +1,9 @@ +from unittest.mock import patch +from pilgrim.command import main + +@patch('pilgrim.command.Application') +def test_main_function_runs_application(MockApplication): + mock_app_instance = MockApplication.return_value + main() + MockApplication.assert_called_once() + mock_app_instance.run.assert_called_once() diff --git a/tests/test_database.py b/tests/test_database.py new file mode 100644 index 0000000..a9529df --- /dev/null +++ b/tests/test_database.py @@ -0,0 +1,40 @@ +import pytest +from unittest.mock import Mock # A ferramenta para criar nosso "dublê" +from pathlib import Path +from sqlalchemy import inspect, Column, Integer, String +from sqlalchemy.orm import Session + +from pilgrim.database import Database,Base + +class MockUser(Base): + __tablename__ = 'mock_users' + id = Column(Integer, primary_key=True) + name = Column(String) + +@pytest.fixture +def db_instance(tmp_path: Path): + fake_db_path = tmp_path / "test_pilgrim.db" + mock_config_manager = Mock() + mock_config_manager.database_url = str(fake_db_path) + db = Database(mock_config_manager) + return db, fake_db_path + +def test_create_database(db_instance): + db, fake_db_path = db_instance + db.create() + assert fake_db_path.exists() + +def test_table_creation(db_instance): + db, _ = db_instance + db.create() + inspector = inspect(db.engine) + assert "mock_users" in inspector.get_table_names() + +def test_session_returned_corectly(db_instance): + db, _ = db_instance + session = db.session() + assert isinstance(session, Session) + session.close() + + + diff --git a/tests/utils/test_config_manager.py b/tests/utils/test_config_manager.py new file mode 100644 index 0000000..511f43e --- /dev/null +++ b/tests/utils/test_config_manager.py @@ -0,0 +1,67 @@ +import pytest +import tomli +from pathlib import Path +from unittest.mock import patch + +from pilgrim.utils.config_manager import ConfigManager, SingletonMeta +from pilgrim.utils.directory_manager import DirectoryManager + +@pytest.fixture +def clean_singleton(): + SingletonMeta._instances = {} + +@patch('pilgrim.utils.config_manager.DirectoryManager.get_config_directory') +def test_create_default_config_if_not_exists_with_decorator(mock_get_config_dir, tmp_path: Path, clean_singleton): + mock_get_config_dir.return_value = str(tmp_path) + manager = ConfigManager() + config_file = tmp_path / "config.toml" + assert not config_file.exists() + manager.read_config() + assert config_file.exists() + assert manager.database_type == "sqlite" + +@patch('pilgrim.utils.config_manager.DirectoryManager.get_config_directory') +def test_read_existing_config_with_decorator(mock_get_config_dir, tmp_path: Path, clean_singleton): + mock_get_config_dir.return_value = str(tmp_path) + custom_config_content = """ + [database] + url = "/custom/path/to/db.sqlite" + type = "custom_sqlite" + [settings.diary] + auto_open_diary_on_startup = "MyCustomDiary" + auto_open_on_creation = true + """ + config_file = tmp_path / "config.toml" + config_file.write_text(custom_config_content) + + manager = ConfigManager() + manager.read_config() + assert manager.database_url == "/custom/path/to/db.sqlite" + assert manager.database_type == "custom_sqlite" + +@patch('pilgrim.utils.config_manager.DirectoryManager.get_config_directory') +def test_save_config_writes_changes_to_file_with_decorator(mock_get_config_dir, tmp_path: Path, clean_singleton): + mock_get_config_dir.return_value = str(tmp_path) + manager = ConfigManager() + manager.read_config() + manager.set_database_url("/novo/caminho.db") + manager.set_auto_open_new_diary(True) + manager.save_config() + config_file = tmp_path / "config.toml" + with open(config_file, "rb") as f: + data = tomli.load(f) + assert data["database"]["url"] == "/novo/caminho.db" + assert data["settings"]["diary"]["auto_open_on_creation"] is True + +@patch('pilgrim.utils.config_manager.DirectoryManager.get_config_directory') +def test_read_config_raises_error_on_malformed_toml(mock_get_config_dir, tmp_path: Path, clean_singleton): + mock_get_config_dir.return_value = str(tmp_path) + invalid_toml_content = """ + [database] + url = /caminho/sem/aspas + """ + config_file = tmp_path / "config.toml" + config_file.write_text(invalid_toml_content) + manager = ConfigManager() + with pytest.raises(ValueError, match="Invalid TOML configuration"): + manager.read_config() \ No newline at end of file diff --git a/tests/utils/test_directory_manager.py b/tests/utils/test_directory_manager.py new file mode 100644 index 0000000..eb3846b --- /dev/null +++ b/tests/utils/test_directory_manager.py @@ -0,0 +1,71 @@ +import shutil +from pathlib import Path +from unittest.mock import patch +import pytest + +from pilgrim.utils.directory_manager import DirectoryManager + +@patch('os.chmod') +@patch('pathlib.Path.home') +def test_get_config_directory_creates_dir_in_fake_home(mock_home, mock_chmod, tmp_path: Path): + mock_home.return_value = tmp_path + + expected_config_dir = tmp_path / ".pilgrim" + assert not expected_config_dir.exists() + result_path = DirectoryManager.get_config_directory() + assert result_path == expected_config_dir + assert expected_config_dir.exists() + mock_chmod.assert_called_once_with(expected_config_dir, 0o700) + +@patch('shutil.copy2') +@patch('pathlib.Path.home') +def test_get_database_path_no_migration(mock_home, mock_copy, tmp_path: Path): + mock_home.return_value = tmp_path + expected_db_path = tmp_path / ".pilgrim" / "database.db" + result_path = DirectoryManager.get_database_path() + assert result_path == expected_db_path + mock_copy.assert_not_called() + +@patch('shutil.copy2') +@patch('pathlib.Path.home') +def test_get_database_path_with_migration(mock_home, mock_copy, tmp_path: Path, monkeypatch): + fake_home_dir = tmp_path / "home" + fake_project_dir = tmp_path / "project" + fake_home_dir.mkdir() + fake_project_dir.mkdir() + + (fake_project_dir / "database.db").touch() + mock_home.return_value = fake_home_dir + monkeypatch.chdir(fake_project_dir) + result_path = DirectoryManager.get_database_path() + expected_db_path = fake_home_dir / ".pilgrim" / "database.db" + assert result_path == expected_db_path + + mock_copy.assert_called_once_with( + Path("database.db"), + expected_db_path + ) + +@patch('os.chmod') +@patch('pathlib.Path.home') +def test_diary_path_methods_construct_correctly(mock_home, mock_chmod, tmp_path: Path): + mock_home.return_value = tmp_path + images_path = DirectoryManager.get_diary_images_directory("minha-viagem") + expected_path = tmp_path / ".pilgrim" / "diaries" / "minha-viagem" / "data" / "images" + assert images_path == expected_path + assert (tmp_path / ".pilgrim" / "diaries").exists() + +@patch('shutil.copy2') +@patch('pathlib.Path.home') +def test_get_database_path_handles_migration_error(mock_home, mock_copy, tmp_path: Path, monkeypatch): + fake_home_dir = tmp_path / "home" + fake_project_dir = tmp_path / "project" + fake_home_dir.mkdir() + fake_project_dir.mkdir() + (fake_project_dir / "database.db").touch() + mock_home.return_value = fake_home_dir + mock_copy.side_effect = shutil.Error("O disco está cheio!") + monkeypatch.chdir(fake_project_dir) + with pytest.raises(RuntimeError, match="Failed to migrate database"): + DirectoryManager.get_database_path() + mock_copy.assert_called_once()