import pytest from django.urls import reverse from datetime import date from plants.models import Plant, PruningLog, Location, PlantCardPhoto def make_location(name): loc, _ = Location.objects.get_or_create(name=name) return loc @pytest.mark.django_db class TestDashboard: def test_returns_200(self, client): resp = client.get(reverse('dashboard')) assert resp.status_code == 200 def test_shows_total_plant_count(self, client): Plant.objects.create(name='Fern', location=make_location('Office')) Plant.objects.create(name='Rose', location=make_location('Garden')) resp = client.get(reverse('dashboard')) assert resp.context['total_plants'] == 2 def test_blooming_now_plant_appears_in_context(self, client): today = date.today() plant = Plant.objects.create(name='Rose', bloom_months=[today.month]) resp = client.get(reverse('dashboard')) assert plant in resp.context['blooming_now'] def test_non_blooming_plant_not_in_blooming_now(self, client): today = date.today() other_month = today.month % 12 + 1 plant = Plant.objects.create(name='Apple', bloom_months=[other_month]) resp = client.get(reverse('dashboard')) assert plant not in resp.context['blooming_now'] @pytest.mark.django_db class TestPlantList: def test_returns_200(self, client): resp = client.get(reverse('plant_list')) assert resp.status_code == 200 def test_lists_plants(self, client): Plant.objects.create(name='Monstera', location=make_location('Living room')) resp = client.get(reverse('plant_list')) assert 'Monstera' in resp.content.decode() def test_search_filters_by_name(self, client): Plant.objects.create(name='Monstera', location=make_location('Living room')) Plant.objects.create(name='Rose', location=make_location('Garden')) resp = client.get(reverse('plant_list') + '?q=Rose') content = resp.content.decode() assert 'Rose' in content assert 'Monstera' not in content def test_htmx_search_uses_partial_template(self, client): resp = client.get( reverse('plant_list') + '?q=rose', HTTP_HX_REQUEST='true', ) assert resp.status_code == 200 assert resp.templates[0].name == 'plants/partials/plant_list_results.html' def test_indoor_filter(self, client): Plant.objects.create(name='Fern', location=make_location('Office'), is_indoor=True) Plant.objects.create(name='Rose', location=make_location('Garden'), is_indoor=False) resp = client.get(reverse('plant_list') + '?filter=indoor') content = resp.content.decode() assert 'Fern' in content assert 'Rose' not in content @pytest.mark.django_db class TestPlantDetail: def test_returns_200(self, client): plant = Plant.objects.create(name='Fern', location=make_location('Office')) resp = client.get(reverse('plant_detail', args=[plant.pk])) assert resp.status_code == 200 def test_404_for_missing_plant(self, client): resp = client.get(reverse('plant_detail', args=[9999])) assert resp.status_code == 404 def test_context_contains_plant_and_status(self, client): plant = Plant.objects.create(name='Rose', pruning_months=[]) resp = client.get(reverse('plant_detail', args=[plant.pk])) assert resp.context['plant'] == plant assert resp.context['pruning_status'] == 'no_schedule' def test_card_button_shown_when_card_photos_exist(self, client): plant = Plant.objects.create(name='Fern') PlantCardPhoto.objects.create(plant=plant, image='plants/cards/card.jpg') resp = client.get(reverse('plant_detail', args=[plant.pk])) assert 'crop-thumbnail' in resp.content.decode() def test_card_button_hidden_when_no_card_photos(self, client): plant = Plant.objects.create(name='Fern') resp = client.get(reverse('plant_detail', args=[plant.pk])) assert 'crop-thumbnail' not in resp.content.decode() @pytest.mark.django_db class TestLogPruning: def test_creates_pruning_log(self, client): plant = Plant.objects.create(name='Rose', location=make_location('Garden')) resp = client.post( reverse('log_pruning', args=[plant.pk]), {'pruned_on': '2026-05-01', 'notes': ''}, ) assert resp.status_code == 200 assert PruningLog.objects.filter(plant=plant, pruned_on='2026-05-01').exists() def test_returns_pruning_strip_partial(self, client): plant = Plant.objects.create(name='Rose', location=make_location('Garden')) resp = client.post( reverse('log_pruning', args=[plant.pk]), {'pruned_on': '2026-05-01', 'notes': ''}, ) assert resp.templates[0].name == 'plants/partials/pruning_strip.html' @pytest.mark.django_db class TestSpeciesSearch: def test_search_page_returns_200(self, client): resp = client.get(reverse('plant_add')) assert resp.status_code == 200 def test_htmx_search_returns_partial(self, client, settings): settings.PERENUAL_API_KEY = '' resp = client.get( reverse('species_search') + '?q=rose', HTTP_HX_REQUEST='true', ) assert resp.status_code == 200 assert resp.templates[0].name == 'plants/partials/species_results.html' def test_species_select_creates_species_and_redirects(self, client): resp = client.post(reverse('species_select'), { 'perenual_id': '42', 'common_name': 'Test Rose', 'scientific_name': 'Rosa testii', 'watering': 'Weekly', 'sunlight': 'Full sun', 'pruning_months': 'February, August', 'api_image_url': '', }) assert resp.status_code == 302 from plants.models import Species assert Species.objects.filter(perenual_id=42).exists() def test_species_select_deduplicates(self, client): from plants.models import Species Species.objects.create(perenual_id=99, common_name='Existing') client.post(reverse('species_select'), { 'perenual_id': '99', 'common_name': 'Existing', 'scientific_name': '', 'watering': '', 'sunlight': '', 'pruning_months': '', 'api_image_url': '', }) assert Species.objects.filter(perenual_id=99).count() == 1 @pytest.mark.django_db class TestPlantAdd: def test_add_with_species_shows_prefilled_form(self, client): from plants.models import Species s = Species.objects.create(common_name='Rose', pruning_months=[2, 8]) resp = client.get(reverse('plant_add') + f'?species_id={s.pk}') assert resp.status_code == 200 assert resp.context['species'] == s def test_post_creates_plant_and_redirects(self, client): resp = client.post(reverse('plant_add'), { 'name': 'New Fern', 'new_location': 'Office', 'is_indoor': 'on', 'notes': '', 'pruning_months': [], }) assert Plant.objects.filter(name='New Fern').exists() plant = Plant.objects.get(name='New Fern') assert resp.status_code == 302 assert resp['Location'] == f'/plants/{plant.pk}/' def test_post_with_species_links_species(self, client): from plants.models import Species s = Species.objects.create(common_name='Rose', pruning_months=[2]) client.post( reverse('plant_add') + f'?species_id={s.pk}', {'name': 'Garden Rose', 'new_location': 'Garden', 'notes': '', 'pruning_months': ['2']}, ) plant = Plant.objects.get(name='Garden Rose') assert plant.species == s @pytest.mark.django_db class TestPlantEdit: def test_edit_form_shows_current_values(self, client): plant = Plant.objects.create(name='Fern', location=make_location('Office'), pruning_months=[3]) resp = client.get(reverse('plant_edit', args=[plant.pk])) assert resp.status_code == 200 assert resp.context['plant'] == plant def test_post_updates_plant(self, client): plant = Plant.objects.create(name='Fern', location=make_location('Office')) client.post(reverse('plant_edit', args=[plant.pk]), { 'name': 'Updated Fern', 'new_location': 'Bedroom', 'notes': '', 'pruning_months': [], }) plant.refresh_from_db() assert plant.name == 'Updated Fern' @pytest.mark.django_db class TestPlantDelete: def test_delete_confirm_page(self, client): plant = Plant.objects.create(name='Fern', location=make_location('Office')) resp = client.get(reverse('plant_delete', args=[plant.pk])) assert resp.status_code == 200 def test_post_deletes_plant(self, client): plant = Plant.objects.create(name='Fern', location=make_location('Office')) client.post(reverse('plant_delete', args=[plant.pk])) assert not Plant.objects.filter(pk=plant.pk).exists() @pytest.mark.django_db class TestPlantCard: def test_returns_200(self, client): plant = Plant.objects.create(name='Fern') resp = client.get(reverse('plant_card', args=[plant.pk])) assert resp.status_code == 200 def test_404_for_missing_plant(self, client): resp = client.get(reverse('plant_card', args=[9999])) assert resp.status_code == 404 def test_context_contains_plant(self, client): plant = Plant.objects.create(name='Fern') resp = client.get(reverse('plant_card', args=[plant.pk])) assert resp.context['plant'] == plant def test_uses_plant_card_template(self, client): plant = Plant.objects.create(name='Fern') resp = client.get(reverse('plant_card', args=[plant.pk])) assert 'plants/plant_card.html' in [t.name for t in resp.templates] @pytest.mark.django_db class TestUploadCardPhoto: def test_upload_creates_card_photo(self, client): from django.core.files.uploadedfile import SimpleUploadedFile plant = Plant.objects.create(name='Fern') image = SimpleUploadedFile('card.jpg', b'\xff\xd8\xff\xe0' + b'\x00' * 100, content_type='image/jpeg') resp = client.post(reverse('upload_card_photo', args=[plant.pk]), {'image': image}) assert resp.status_code == 200 assert PlantCardPhoto.objects.filter(plant=plant).count() == 1 def test_upload_blocked_at_max_3(self, client): from django.core.files.uploadedfile import SimpleUploadedFile plant = Plant.objects.create(name='Fern') for i in range(3): PlantCardPhoto.objects.create(plant=plant, image=f'plants/cards/card{i}.jpg') image = SimpleUploadedFile('card_extra.jpg', b'\xff\xd8\xff\xe0' + b'\x00' * 100, content_type='image/jpeg') client.post(reverse('upload_card_photo', args=[plant.pk]), {'image': image}) assert PlantCardPhoto.objects.filter(plant=plant).count() == 3 def test_returns_card_gallery_partial(self, client): plant = Plant.objects.create(name='Fern') resp = client.post(reverse('upload_card_photo', args=[plant.pk]), {}) assert resp.templates[0].name == 'plants/partials/card_gallery.html' @pytest.mark.django_db class TestDeleteCardPhoto: def test_delete_removes_record(self, client): plant = Plant.objects.create(name='Fern') photo = PlantCardPhoto.objects.create(plant=plant, image='plants/cards/card.jpg') resp = client.post(reverse('delete_card_photo', args=[photo.pk])) assert resp.status_code == 200 assert not PlantCardPhoto.objects.filter(pk=photo.pk).exists() def test_returns_card_gallery_partial(self, client): plant = Plant.objects.create(name='Fern') photo = PlantCardPhoto.objects.create(plant=plant, image='plants/cards/card.jpg') resp = client.post(reverse('delete_card_photo', args=[photo.pk])) assert resp.templates[0].name == 'plants/partials/card_gallery.html' def test_404_for_missing_photo(self, client): resp = client.post(reverse('delete_card_photo', args=[9999])) assert resp.status_code == 404 @pytest.mark.django_db class TestPruningCalendar: def test_returns_200(self, client): resp = client.get(reverse('pruning_calendar')) assert resp.status_code == 200 def test_overdue_plant_in_context(self, client): plant = Plant.objects.create(name='Rose', location=make_location('Garden'), pruning_months=[3]) resp = client.get(reverse('pruning_calendar')) assert plant in resp.context['overdue'] def test_due_this_month_in_context(self, client): from datetime import date today = date.today() plant = Plant.objects.create(name='Apple', location=make_location('Garden'), pruning_months=[today.month]) resp = client.get(reverse('pruning_calendar')) assert plant in resp.context['due_this_month'] def test_no_schedule_plants_excluded(self, client): plant = Plant.objects.create(name='Fern', location=make_location('Office'), pruning_months=[]) resp = client.get(reverse('pruning_calendar')) assert plant not in resp.context['overdue'] assert plant not in resp.context['due_this_month']