diff --git a/src/application/usecases/recipes/import_recipes_from_uploaded_qr_cards.py b/src/application/usecases/recipes/import_recipes_from_uploaded_qr_cards.py index a0a2817..4df6d29 100644 --- a/src/application/usecases/recipes/import_recipes_from_uploaded_qr_cards.py +++ b/src/application/usecases/recipes/import_recipes_from_uploaded_qr_cards.py @@ -25,8 +25,14 @@ def import_recipes_from_uploaded_qr_cards( invalid payload, are recorded as failures; processing continues with the remaining files. Each failure is also published as an event so the reason is visible in the dev terminal and the events log file. + + A card whose settings match a recipe already in the library does not + create anything, so the successes are also split into the recipes the + import created and the ones it completed by naming them. """ imported: list[models.FujifilmRecipe] = [] + created: list[models.FujifilmRecipe] = [] + updated: list[models.FujifilmRecipe] = [] failed: list[str] = [] for file in files: @@ -35,8 +41,14 @@ def import_recipes_from_uploaded_qr_cards( with tempfile.NamedTemporaryFile(suffix=".jpg", dir="/tmp/", delete=False) as tmp: tmp.write(file.content) tmp_path = tmp.name - recipe, _ = operations.get_or_create_recipe_from_qr_card(filepath=tmp_path) + recipe, outcome = operations.get_or_create_recipe_from_qr_card_and_backfill_name( + filepath=tmp_path + ) imported.append(recipe) + if outcome is recipe_dataclasses.RecipeImportOutcome.CREATED: + created.append(recipe) + elif outcome is recipe_dataclasses.RecipeImportOutcome.NAME_BACKFILLED: + updated.append(recipe) except QRCodeNotFoundError: failed.append(file.name) events.publish_event( @@ -58,4 +70,6 @@ def import_recipes_from_uploaded_qr_cards( return recipe_dataclasses.ImportRecipesResult( imported=tuple(imported), failed=tuple(failed), + created=tuple(created), + updated=tuple(updated), ) diff --git a/src/domain/images/events.py b/src/domain/images/events.py index 96cc555..62510e4 100644 --- a/src/domain/images/events.py +++ b/src/domain/images/events.py @@ -8,6 +8,7 @@ RECIPE_DEDUPLICATED = "recipe.deduplicated" RECIPE_IMAGE_CREATED = "recipe.image.created" RECIPE_IMAGE_UPDATED = "recipe.image.updated" +RECIPE_NAME_UPDATED = "recipe.name.updated" RECIPE_COVER_IMAGE_SET = "recipe.cover.image.set" RECIPE_SENSORS_SET = "recipe.sensors.set" RECIPE_CARD_CREATED = "recipe.card.created" diff --git a/src/domain/recipes/cards/queries.py b/src/domain/recipes/cards/queries.py index 217d1bd..fde5115 100644 --- a/src/domain/recipes/cards/queries.py +++ b/src/domain/recipes/cards/queries.py @@ -223,6 +223,9 @@ class InvalidQRRecipePayloadError(Exception): - ``"unknown_fields"`` — payload contains keys outside the known schema. - ``"type_mismatch"`` — a field's value has the wrong type, or a required field is missing. + - ``"invalid_field_value"`` — a field's value has the right type but is + not a legal value (an over-long or non-ASCII name, or an unknown + sensor name). """ image_path: str = "" @@ -379,7 +382,7 @@ def _signed_decimal_or_none(value: int | float | None) -> str | None: def get_recipe_data_from_qr_recipe( - *, qr_recipe: card_dataclasses.QRFujifilmRecipe, + *, qr_recipe: card_dataclasses.QRFujifilmRecipe, image_path: str, ) -> image_dataclasses.FujifilmRecipeData: """ Translate a decoded QRFujifilmRecipe into a FujifilmRecipeData. @@ -397,31 +400,43 @@ def get_recipe_data_from_qr_recipe( - grain_size when grain_roughness is "Off". - dynamic_range/highlight/shadow when DRP is active. - color for mono sims; mono color fields for colour sims. + + :raises InvalidQRRecipePayloadError: If a field's value passes the payload + type checks but is rejected by the FujifilmRecipeData validators (an + over-long or non-ASCII name, or a sensor name this deployment does not + know). """ - return recipe_normalization.normalize_recipe_data( - image_dataclasses.FujifilmRecipeData( - name=qr_recipe.name or "", - film_simulation=qr_recipe.film_simulation, - grain_roughness=qr_recipe.grain_roughness, - d_range_priority=qr_recipe.d_range_priority, - white_balance=qr_recipe.white_balance, - white_balance_red=qr_recipe.white_balance_red, - white_balance_blue=qr_recipe.white_balance_blue, - color_chrome_effect=qr_recipe.color_chrome_effect or "Off", - color_chrome_fx_blue=qr_recipe.color_chrome_fx_blue or "Off", - sharpness=_signed_decimal_or_none(qr_recipe.sharpness) or "0", - high_iso_nr=_signed_decimal_or_none(qr_recipe.high_iso_nr) or "0", - clarity=_signed_decimal_or_none(qr_recipe.clarity) or "0", - dynamic_range=qr_recipe.dynamic_range if qr_recipe.dynamic_range is not None else "", - grain_size=qr_recipe.grain_size, - highlight=_signed_decimal_or_none(qr_recipe.highlight) or "0", - shadow=_signed_decimal_or_none(qr_recipe.shadow) or "0", - color=_signed_decimal_or_none(qr_recipe.color) or "0", - monochromatic_color_warm_cool=_signed_decimal_or_none(qr_recipe.monochromatic_color_warm_cool) or "0", - monochromatic_color_magenta_green=_signed_decimal_or_none(qr_recipe.monochromatic_color_magenta_green) or "0", - # v=1 payloads omit ``sensors``; the dataclass leaves it None there. - # v=2 payloads may include it; either way we settle on an empty - # tuple when absent so the FujifilmRecipeData validator is happy. - sensors=qr_recipe.sensors if qr_recipe.sensors is not None else (), + try: + return recipe_normalization.normalize_recipe_data( + image_dataclasses.FujifilmRecipeData( + name=qr_recipe.name or "", + film_simulation=qr_recipe.film_simulation, + grain_roughness=qr_recipe.grain_roughness, + d_range_priority=qr_recipe.d_range_priority, + white_balance=qr_recipe.white_balance, + white_balance_red=qr_recipe.white_balance_red, + white_balance_blue=qr_recipe.white_balance_blue, + color_chrome_effect=qr_recipe.color_chrome_effect or "Off", + color_chrome_fx_blue=qr_recipe.color_chrome_fx_blue or "Off", + sharpness=_signed_decimal_or_none(qr_recipe.sharpness) or "0", + high_iso_nr=_signed_decimal_or_none(qr_recipe.high_iso_nr) or "0", + clarity=_signed_decimal_or_none(qr_recipe.clarity) or "0", + dynamic_range=qr_recipe.dynamic_range if qr_recipe.dynamic_range is not None else "", + grain_size=qr_recipe.grain_size, + highlight=_signed_decimal_or_none(qr_recipe.highlight) or "0", + shadow=_signed_decimal_or_none(qr_recipe.shadow) or "0", + color=_signed_decimal_or_none(qr_recipe.color) or "0", + monochromatic_color_warm_cool=_signed_decimal_or_none(qr_recipe.monochromatic_color_warm_cool) or "0", + monochromatic_color_magenta_green=_signed_decimal_or_none(qr_recipe.monochromatic_color_magenta_green) or "0", + # v=1 payloads omit ``sensors``; the dataclass leaves it None there. + # v=2 payloads may include it; either way we settle on an empty + # tuple when absent so the FujifilmRecipeData validator is happy. + sensors=qr_recipe.sensors if qr_recipe.sensors is not None else (), + ) ) - ) + except ValueError: + # The FujifilmRecipeData validators raise plain ValueErrors. Translating + # them here keeps every bad-payload failure on the same exception, so a + # single unimportable card is recorded as a failed file by the caller + # instead of aborting the whole upload. + raise InvalidQRRecipePayloadError(image_path=image_path, reason="invalid_field_value") diff --git a/src/domain/recipes/dataclasses.py b/src/domain/recipes/dataclasses.py index 262169a..58c737b 100644 --- a/src/domain/recipes/dataclasses.py +++ b/src/domain/recipes/dataclasses.py @@ -1,10 +1,26 @@ from __future__ import annotations +import enum + import attrs from src.data import models +class RecipeImportOutcome(enum.Enum): + """ + What an import did to the library for one incoming recipe. + + A recipe arriving from a shared card is matched against the existing + library by its settings, so importing it does not necessarily create + anything. + """ + + CREATED = "created" + NAME_BACKFILLED = "name_backfilled" # matched an existing recipe that had no name + UNCHANGED = "unchanged" # matched an existing recipe; nothing to add + + @attrs.frozen class UploadedFile: """ @@ -17,5 +33,29 @@ class UploadedFile: @attrs.frozen class ImportRecipesResult: + """ + The outcome of importing a batch of files, one entry per file. + + Importing a file does not necessarily add a recipe: the recipe it carries + is matched against the library by its settings, and may already be there. + So a successfully imported file lands in ``imported``, and additionally in + ``created`` if it added a recipe to the library, or in ``updated`` if it + completed a recipe that was already there by giving it a name. + + A file that matched an existing recipe and had nothing to add appears only + in ``imported``. Two files carrying the same recipe both appear, so these + are counts of files, not of distinct recipes. + + Only the QR-card import can tell these apart. The import that reads + recipes out of image EXIF leaves ``created`` and ``updated`` empty. + + :param imported: recipes read successfully, one per file. + :param failed: filenames that could not be read. + :param created: recipes that were new to the library. + :param updated: recipes that were already in the library and got named. + """ + imported: tuple[models.FujifilmRecipe, ...] - failed: tuple[str, ...] # original filenames that could not be processed + failed: tuple[str, ...] + created: tuple[models.FujifilmRecipe, ...] = () + updated: tuple[models.FujifilmRecipe, ...] = () diff --git a/src/domain/recipes/operations.py b/src/domain/recipes/operations.py index d98cbe5..8a4276d 100644 --- a/src/domain/recipes/operations.py +++ b/src/domain/recipes/operations.py @@ -12,6 +12,7 @@ from src.domain.images import queries as image_queries from collections.abc import Iterable +from src.domain.recipes import dataclasses as recipe_dataclasses from src.domain.recipes import normalization as recipe_normalization from src.domain.recipes import queries as recipe_queries from src.domain.recipes import sensors as recipe_sensors @@ -239,6 +240,9 @@ def get_or_create_recipe_from_data( This is the single seam for ``FujifilmRecipe.get_or_create`` — shared by every caller that has already produced a FujifilmRecipeData (from EXIF, from a QR card, or any future source). + + Callers importing a recipe shared from another library want a name that + survives the get path: they use ``get_or_create_recipe_and_backfill_name``. """ data = recipe_normalization.normalize_recipe_data(data) recipe_validation.validate_recipe_data(data) @@ -341,6 +345,41 @@ def get_or_create_recipe_from_data( return recipe, created +def get_or_create_recipe_and_backfill_name( + *, + data: image_dataclasses.FujifilmRecipeData, + group_id: int | None = None, +) -> tuple[models.FujifilmRecipe, recipe_dataclasses.RecipeImportOutcome]: + """ + Get or create the recipe for *data*, naming it if it has no name. + + For recipes shared from another library, where the settings already match + an existing recipe but the local copy may predate that recipe being named. + + Backfill only: an existing recipe is written to under exactly one + condition, that its ``name`` is empty and *data* carries one. A name + chosen locally is never overwritten, and ``description`` is never touched + (a QR card does not carry one). + + local "" + incoming "Kodachrome" -> "Kodachrome" (backfilled) + local "My CC" + incoming "Kodachrome" -> "My CC" (kept) + local "My CC" + incoming "" -> "My CC" (kept) + + Returns the recipe and what the import did to the library, so a caller + importing a batch can report how many recipes it created versus updated. + """ + recipe, created = get_or_create_recipe_from_data(data=data, group_id=group_id) + if created: + return recipe, recipe_dataclasses.RecipeImportOutcome.CREATED + if data.name and not recipe.name: + # No RecipeNameValidationError handling: the name is empty (excluded + # by the guard) or it came through FujifilmRecipeData, whose validator + # enforces the same rules set_recipe_name checks. + set_recipe_name(recipe=recipe, name=data.name) + return recipe, recipe_dataclasses.RecipeImportOutcome.NAME_BACKFILLED + return recipe, recipe_dataclasses.RecipeImportOutcome.UNCHANGED + + def get_or_create_recipe_from_metadata( *, metadata: image_dataclasses.ImageExifData, ) -> tuple[models.FujifilmRecipe, bool]: @@ -370,19 +409,25 @@ def get_or_create_recipe_from_filepath( return get_or_create_recipe_from_metadata(metadata=metadata) -def get_or_create_recipe_from_qr_card( +def get_or_create_recipe_from_qr_card_and_backfill_name( *, filepath: str, -) -> tuple[models.FujifilmRecipe, bool]: +) -> tuple[models.FujifilmRecipe, recipe_dataclasses.RecipeImportOutcome]: """ Decode the QR on a recipe-card image and return the matching FujifilmRecipe. + The card names the recipe it matches when that recipe has no name of its + own — see ``get_or_create_recipe_and_backfill_name``, which also explains + what the returned outcome means. + :raises QRCodeNotFoundError: If no QR code can be decoded from *filepath*. :raises InvalidQRRecipePayloadError: If the decoded content is not a valid QRFujifilmRecipe payload. """ qr_recipe = card_queries.get_qr_recipe_from_image(image_path=filepath) - recipe_data = card_queries.get_recipe_data_from_qr_recipe(qr_recipe=qr_recipe) - return get_or_create_recipe_from_data(data=recipe_data) + recipe_data = card_queries.get_recipe_data_from_qr_recipe( + qr_recipe=qr_recipe, image_path=filepath, + ) + return get_or_create_recipe_and_backfill_name(data=recipe_data) @attrs.frozen @@ -464,7 +509,7 @@ def set_recipe_name(*, recipe: models.FujifilmRecipe, name: str) -> None: raise RecipeNameValidationError(name) recipe.set_name(name=name) events.publish_event( - event_type=events.RECIPE_IMAGE_UPDATED, + event_type=events.RECIPE_NAME_UPDATED, name=name, recipe_id=recipe.pk, ) diff --git a/src/interfaces/recipes/views.py b/src/interfaces/recipes/views.py index 1350b69..35054d4 100644 --- a/src/interfaces/recipes/views.py +++ b/src/interfaces/recipes/views.py @@ -686,7 +686,12 @@ def post(self, request: http.HttpRequest) -> http.HttpResponse: return shortcuts.render( request, "recipes/partials/_import_result.html", - {"imported": result.imported, "failed": result.failed}, + { + "imported": result.imported, + "failed": result.failed, + "created_count": len(result.created), + "updated_count": len(result.updated), + }, ) diff --git a/src/interfaces/templates/recipes/partials/_import_result.html b/src/interfaces/templates/recipes/partials/_import_result.html index eafea51..6e0c781 100644 --- a/src/interfaces/templates/recipes/partials/_import_result.html +++ b/src/interfaces/templates/recipes/partials/_import_result.html @@ -11,6 +11,13 @@

{% endif %} + {% if created_count or updated_count %} +

+ {{ created_count }} recipe{{ created_count|pluralize }} created, + {{ updated_count }} existing recipe{{ updated_count|pluralize }} updated. +

+ {% endif %} + {% if failed %}

{{ failed|length }} file{{ failed|length|pluralize }} could not be processed: diff --git a/tests/functional/test_import_recipes_from_qr_cards_view.py b/tests/functional/test_import_recipes_from_qr_cards_view.py index 75a76b8..1be0480 100644 --- a/tests/functional/test_import_recipes_from_qr_cards_view.py +++ b/tests/functional/test_import_recipes_from_qr_cards_view.py @@ -1,8 +1,10 @@ +import json from io import BytesIO from pathlib import Path from unittest.mock import patch import pytest +import qrcode # type: ignore[import-untyped] from bs4 import BeautifulSoup from src.data import models @@ -24,6 +26,39 @@ def _post(client, *filenames: str): return client.post("/recipes/import-qr-cards/", data, format="multipart") +def _card_upload(payload: dict[str, object], *, filename: str = "card.png") -> BytesIO: + """Render *payload* as a QR card image the view can accept as an upload.""" + buffer = BytesIO() + qrcode.make(json.dumps(payload), box_size=10).save(buffer, format="PNG") + buffer.seek(0) + buffer.name = filename + return buffer + + +def _post_card(client, payload: dict[str, object], *, filename: str = "card.png"): + return client.post( + "/recipes/import-qr-cards/", + {"images": _card_upload(payload, filename=filename)}, + format="multipart", + ) + + +def _text(response) -> str: + """The response's visible text, with the template's line breaks collapsed.""" + return " ".join(BeautifulSoup(response.content, "html.parser").get_text().split()) + + +PROVIA_PAYLOAD: dict[str, object] = { + "v": 1, + "film_simulation": "Provia", + "grain_roughness": "Off", + "d_range_priority": "Off", + "white_balance": "Auto", + "white_balance_red": 0, + "white_balance_blue": 0, +} + + @pytest.mark.django_db class TestRecipesExplorerImportCardsOption: def test_import_cards_option_is_present(self, client): @@ -84,6 +119,38 @@ def test_deduplicates_same_card(self, client): assert models.FujifilmRecipe.objects.count() == 1 +@pytest.mark.django_db +class TestImportRecipesFromQRCardsViewOutcomeBreakdown: + """ + A bulk import of shared cards mostly matches recipes the library already + has, so the result says how many it created and how many it completed. + """ + + def test_response_reports_a_new_recipe_as_created(self, client): + response = _post(client, "card_classic_chrome.jpg") + + assert "1 recipe created, 0 existing recipes updated" in _text(response) + + def test_response_reports_a_card_that_names_an_existing_recipe_as_updated(self, client): + _post_card(client, PROVIA_PAYLOAD, filename="nameless.png") + + response = _post_card( + client, {**PROVIA_PAYLOAD, "name": "Kodachrome"}, filename="named.png" + ) + + assert "0 recipes created, 1 existing recipe updated" in _text(response) + assert models.FujifilmRecipe.objects.count() == 1 + assert models.FujifilmRecipe.objects.get().name == "Kodachrome" + + def test_response_omits_the_breakdown_when_nothing_changed(self, client): + _post(client, "card_classic_chrome.jpg") + + response = _post(client, "card_classic_chrome.jpg") + + assert "created," not in _text(response) + assert "1 recipe imported successfully" in _text(response) + + @pytest.mark.django_db class TestImportRecipesFromQRCardsViewFailure: def test_no_files_returns_error_message(self, client): diff --git a/tests/integration/application/recipes/test_import_recipes_from_uploaded_qr_cards.py b/tests/integration/application/recipes/test_import_recipes_from_uploaded_qr_cards.py index 8a173d4..2d126b7 100644 --- a/tests/integration/application/recipes/test_import_recipes_from_uploaded_qr_cards.py +++ b/tests/integration/application/recipes/test_import_recipes_from_uploaded_qr_cards.py @@ -161,3 +161,87 @@ def test_empty_file_list_returns_empty_result(self) -> None: result = import_recipes_from_uploaded_qr_cards(files=[]) assert result == ImportRecipesResult(imported=(), failed=()) + + +def _payload(**overrides: object) -> dict[str, object]: + payload: dict[str, object] = { + "v": 1, + "film_simulation": "Provia", + "grain_roughness": "Off", + "d_range_priority": "Off", + "white_balance": "Auto", + "white_balance_red": 0, + "white_balance_blue": 0, + } + payload.update(overrides) + return payload + + +@pytest.mark.django_db +class TestImportRecipesFromUploadedQRCardsOutcomes: + """ + Importing a card does not necessarily create a recipe: its settings may + already match one. The result says which happened, so a bulk import can + report what it did to the library. + """ + + def test_reports_a_new_recipe_as_created(self, tmp_path: Path) -> None: + card = _qr_file(tmp_path, json.dumps(_payload(name="Kodachrome")), filename="card.png") + + result = import_recipes_from_uploaded_qr_cards(files=[card]) + + assert result.created == result.imported + assert result.updated == () + + def test_reports_a_card_that_names_an_existing_recipe_as_updated(self, tmp_path: Path) -> None: + nameless = _qr_file(tmp_path, json.dumps(_payload()), filename="nameless.png") + import_recipes_from_uploaded_qr_cards(files=[nameless]) + + named = _qr_file(tmp_path, json.dumps(_payload(name="Kodachrome")), filename="named.png") + result = import_recipes_from_uploaded_qr_cards(files=[named]) + + assert result.created == () + assert len(result.updated) == 1 + assert result.updated[0].name == "Kodachrome" + assert models.FujifilmRecipe.objects.count() == 1 + + def test_reports_a_card_matching_a_named_recipe_as_neither(self, tmp_path: Path) -> None: + card = _qr_file(tmp_path, json.dumps(_payload(name="Kodachrome")), filename="card.png") + import_recipes_from_uploaded_qr_cards(files=[card]) + + result = import_recipes_from_uploaded_qr_cards(files=[card]) + + assert len(result.imported) == 1 + assert result.created == () + assert result.updated == () + + def test_a_card_with_an_illegal_value_fails_alone(self, tmp_path: Path) -> None: + # A card exported by a library that knows a sensor this one doesn't. + # It must not take the rest of the batch down with it. + unknown_sensor = _qr_file( + tmp_path, + json.dumps(_payload(v=2, sensors=["X-Trans VI"])), + filename="future.png", + ) + good = uploaded_file_from_fixture("card_classic_chrome.jpg") + + result = import_recipes_from_uploaded_qr_cards(files=[unknown_sensor, good]) + + assert result.failed == ("future.png",) + assert len(result.imported) == 1 + assert result.imported[0].film_simulation == "Classic Chrome" + + def test_publishes_the_invalid_value_reason_for_a_card_that_fails_alone( + self, tmp_path: Path, captured_logs + ) -> None: + too_long = _qr_file( + tmp_path, json.dumps(_payload(name="x" * 26)), filename="long_name.png" + ) + + import_recipes_from_uploaded_qr_cards(files=[too_long]) + + failure_events = [ + e for e in captured_logs if e.get("event_type") == events.RECIPE_IMPORT_QR_CARD_FAILED + ] + assert len(failure_events) == 1 + assert failure_events[0]["failure_reason"] == "invalid_field_value" diff --git a/tests/integration/domain/recipes/test_get_or_create_recipe_and_backfill_name.py b/tests/integration/domain/recipes/test_get_or_create_recipe_and_backfill_name.py new file mode 100644 index 0000000..e1314ef --- /dev/null +++ b/tests/integration/domain/recipes/test_get_or_create_recipe_and_backfill_name.py @@ -0,0 +1,149 @@ +import pytest + +from src.data import models +from src.domain.images import dataclasses as image_dataclasses +from src.domain.images import events +from src.domain.recipes.dataclasses import RecipeImportOutcome +from src.domain.recipes.operations import ( + get_or_create_recipe_and_backfill_name, + get_or_create_recipe_from_data, +) + + +def _make_data(**overrides: object) -> image_dataclasses.FujifilmRecipeData: + base = dict( + film_simulation="Provia", + d_range_priority="Off", + grain_roughness="Off", + color_chrome_effect="Off", + color_chrome_fx_blue="Off", + white_balance="Auto", + white_balance_red=0, + white_balance_blue=0, + sharpness="0", + high_iso_nr="0", + clarity="0", + dynamic_range="DR100", + highlight="0", + shadow="0", + color="0", + ) + base.update(overrides) + return image_dataclasses.FujifilmRecipeData(**base) # type: ignore[arg-type] # test helper merges typed defaults with arbitrary overrides via dict + + +@pytest.mark.django_db +class TestGetOrCreateRecipeAndBackfillName: + def test_creates_the_recipe_when_the_library_has_no_match(self) -> None: + recipe, outcome = get_or_create_recipe_and_backfill_name(data=_make_data(name="Kodachrome")) + + assert recipe.name == "Kodachrome" + assert outcome is RecipeImportOutcome.CREATED + + def test_backfills_the_name_of_a_matching_recipe_that_has_none(self) -> None: + existing, _ = get_or_create_recipe_from_data(data=_make_data(name="")) + + recipe, outcome = get_or_create_recipe_and_backfill_name(data=_make_data(name="Kodachrome")) + + assert recipe.pk == existing.pk + assert outcome is RecipeImportOutcome.NAME_BACKFILLED + existing.refresh_from_db() + assert existing.name == "Kodachrome" + assert models.FujifilmRecipe.objects.count() == 1 + + def test_keeps_a_name_already_chosen_locally(self) -> None: + existing, _ = get_or_create_recipe_from_data(data=_make_data(name="My Chrome")) + + recipe, outcome = get_or_create_recipe_and_backfill_name(data=_make_data(name="Kodachrome")) + + assert recipe.pk == existing.pk + assert outcome is RecipeImportOutcome.UNCHANGED + existing.refresh_from_db() + assert existing.name == "My Chrome" + + def test_leaves_an_unnamed_recipe_unnamed_when_the_incoming_data_has_no_name(self) -> None: + existing, _ = get_or_create_recipe_from_data(data=_make_data(name="")) + + recipe, outcome = get_or_create_recipe_and_backfill_name(data=_make_data(name="")) + + assert recipe.pk == existing.pk + assert outcome is RecipeImportOutcome.UNCHANGED + assert recipe.name == "" + + def test_never_writes_the_description(self) -> None: + existing, _ = get_or_create_recipe_from_data(data=_make_data(name="", description="")) + + get_or_create_recipe_and_backfill_name( + data=_make_data(name="Kodachrome", description="Notes from the other library") + ) + + existing.refresh_from_db() + assert existing.description == "" + + def test_publishes_the_name_updated_event_when_it_backfills( + self, captured_logs: list[dict[str, object]] + ) -> None: + existing, _ = get_or_create_recipe_from_data(data=_make_data(name="")) + captured_logs.clear() + + get_or_create_recipe_and_backfill_name(data=_make_data(name="Kodachrome")) + + name_events = [e for e in captured_logs if e.get("event_type") == events.RECIPE_NAME_UPDATED] + assert len(name_events) == 1 + assert name_events[0]["recipe_id"] == existing.pk + assert name_events[0]["name"] == "Kodachrome" + + def test_publishes_no_name_updated_event_when_nothing_is_backfilled( + self, captured_logs: list[dict[str, object]] + ) -> None: + get_or_create_recipe_from_data(data=_make_data(name="My Chrome")) + captured_logs.clear() + + get_or_create_recipe_and_backfill_name(data=_make_data(name="Kodachrome")) + + assert not [e for e in captured_logs if e.get("event_type") == events.RECIPE_NAME_UPDATED] + + +@pytest.mark.django_db +class TestGetOrCreateRecipeAndBackfillNameWithSensors: + """ + The case this exists for: recipes shared from a newer library into an + older one, where the local copies predate both sensor tracking and being + named. Each card must find its local recipe and complete it, not duplicate it. + """ + + def test_backfills_name_and_sensors_of_a_sensorless_local_recipe(self) -> None: + local, _ = get_or_create_recipe_from_data( + data=_make_data(name="", sensors=(), white_balance_red=9001) + ) + assert local.sensor_signature == "" + + recipe, outcome = get_or_create_recipe_and_backfill_name( + data=_make_data(name="Kodachrome", sensors=("X-Trans V",), white_balance_red=9001) + ) + + assert recipe.pk == local.pk + assert outcome is RecipeImportOutcome.NAME_BACKFILLED + assert models.FujifilmRecipe.objects.count() == 1 + local.refresh_from_db() + assert local.name == "Kodachrome" + assert [s.name for s in local.sensors.all()] == ["X-Trans V"] + assert local.sensor_signature == "x-trans v" + + def test_creates_a_separate_recipe_for_a_different_sensor_set(self) -> None: + # Two cards with identical settings but different sensors are two + # recipes. The first claims the sensorless local recipe; the second + # can no longer match it and is created. + get_or_create_recipe_from_data(data=_make_data(name="", sensors=(), white_balance_red=9002)) + + first, first_outcome = get_or_create_recipe_and_backfill_name( + data=_make_data(name="Chrome IV", sensors=("X-Trans IV",), white_balance_red=9002) + ) + second, second_outcome = get_or_create_recipe_and_backfill_name( + data=_make_data(name="Chrome V", sensors=("X-Trans V",), white_balance_red=9002) + ) + + assert first_outcome is RecipeImportOutcome.NAME_BACKFILLED + assert second_outcome is RecipeImportOutcome.CREATED + assert first.pk != second.pk + assert models.FujifilmRecipe.objects.count() == 2 diff --git a/tests/integration/domain/recipes/test_get_or_create_recipe_from_qr_card.py b/tests/integration/domain/recipes/test_get_or_create_recipe_from_qr_card_and_backfill_name.py similarity index 71% rename from tests/integration/domain/recipes/test_get_or_create_recipe_from_qr_card.py rename to tests/integration/domain/recipes/test_get_or_create_recipe_from_qr_card_and_backfill_name.py index 2fc6460..50a589b 100644 --- a/tests/integration/domain/recipes/test_get_or_create_recipe_from_qr_card.py +++ b/tests/integration/domain/recipes/test_get_or_create_recipe_from_qr_card_and_backfill_name.py @@ -8,6 +8,7 @@ from src.domain.images import events from src.domain.recipes import operations as recipe_operations from src.domain.recipes.cards import queries as card_queries +from src.domain.recipes.dataclasses import RecipeImportOutcome FIXTURES_DIR = Path(__file__).resolve().parent.parent.parent.parent / "fixtures" / "recipe_cards" CLASSIC_CHROME_CARD = str(FIXTURES_DIR / "card_classic_chrome.jpg") @@ -25,9 +26,9 @@ def _write_qr(tmp_path: Path, payload_str: str) -> str: @pytest.mark.django_db -class TestGetOrCreateRecipeFromQRCard: +class TestGetOrCreateRecipeFromQRCardAndBackfillName: def test_creates_recipe_from_colour_card_fixture(self) -> None: - recipe, created = recipe_operations.get_or_create_recipe_from_qr_card(filepath=CLASSIC_CHROME_CARD) + recipe, outcome = recipe_operations.get_or_create_recipe_from_qr_card_and_backfill_name(filepath=CLASSIC_CHROME_CARD) assert isinstance(recipe, models.FujifilmRecipe) assert recipe.pk is not None @@ -36,10 +37,10 @@ def test_creates_recipe_from_colour_card_fixture(self) -> None: assert recipe.white_balance_red == 2 assert recipe.white_balance_blue == -1 assert recipe.color_chrome_effect == "Strong" - assert created is True + assert outcome is RecipeImportOutcome.CREATED def test_creates_recipe_from_monochromatic_card_fixture(self) -> None: - recipe, _ = recipe_operations.get_or_create_recipe_from_qr_card(filepath=ACROS_CARD) + recipe, _ = recipe_operations.get_or_create_recipe_from_qr_card_and_backfill_name(filepath=ACROS_CARD) assert recipe.film_simulation == "Acros STD" assert recipe.grain_roughness == "Off" @@ -50,7 +51,7 @@ def test_creates_recipe_from_monochromatic_card_fixture(self) -> None: assert float(recipe.monochromatic_color_warm_cool) == -2.0 def test_publishes_recipe_created_event_on_first_import(self, captured_logs) -> None: - recipe, _ = recipe_operations.get_or_create_recipe_from_qr_card(filepath=CLASSIC_CHROME_CARD) + recipe, _ = recipe_operations.get_or_create_recipe_from_qr_card_and_backfill_name(filepath=CLASSIC_CHROME_CARD) created_events = [e for e in captured_logs if e.get("event_type") == events.RECIPE_CREATED] assert len(created_events) == 1 @@ -58,13 +59,13 @@ def test_publishes_recipe_created_event_on_first_import(self, captured_logs) -> assert created_events[0]["film_simulation"] == "Classic Chrome" def test_returns_existing_recipe_on_reimport(self, captured_logs) -> None: - first, _ = recipe_operations.get_or_create_recipe_from_qr_card(filepath=CLASSIC_CHROME_CARD) + first, _ = recipe_operations.get_or_create_recipe_from_qr_card_and_backfill_name(filepath=CLASSIC_CHROME_CARD) captured_logs.clear() - second, created = recipe_operations.get_or_create_recipe_from_qr_card(filepath=CLASSIC_CHROME_CARD) + second, outcome = recipe_operations.get_or_create_recipe_from_qr_card_and_backfill_name(filepath=CLASSIC_CHROME_CARD) assert second.pk == first.pk - assert created is False + assert outcome is not RecipeImportOutcome.CREATED assert models.FujifilmRecipe.objects.count() == 1 created_events = [e for e in captured_logs if e.get("event_type") == events.RECIPE_CREATED] assert created_events == [] @@ -82,7 +83,7 @@ def test_saves_name_from_payload_on_first_create(self, tmp_path: Path) -> None: } card = _write_qr(tmp_path, json.dumps(payload)) - recipe, _ = recipe_operations.get_or_create_recipe_from_qr_card(filepath=card) + recipe, _ = recipe_operations.get_or_create_recipe_from_qr_card_and_backfill_name(filepath=card) assert recipe.name == "Shared Recipe" @@ -97,19 +98,44 @@ def test_does_not_overwrite_name_on_dedup(self, tmp_path: Path) -> None: "white_balance_red": 4, "white_balance_blue": 4, } - first, _ = recipe_operations.get_or_create_recipe_from_qr_card( + first, _ = recipe_operations.get_or_create_recipe_from_qr_card_and_backfill_name( filepath=_write_qr(tmp_path, json.dumps(base_payload)), ) - second, created = recipe_operations.get_or_create_recipe_from_qr_card( + second, outcome = recipe_operations.get_or_create_recipe_from_qr_card_and_backfill_name( filepath=_write_qr(tmp_path, json.dumps({**base_payload, "name": "Different Name"})), ) assert second.pk == first.pk - assert created is False + assert outcome is RecipeImportOutcome.UNCHANGED second.refresh_from_db() assert second.name == "First Name" + def test_names_a_matching_recipe_that_has_no_name(self, tmp_path: Path) -> None: + payload = { + "v": 1, + "film_simulation": "Provia", + "grain_roughness": "Off", + "d_range_priority": "Off", + "white_balance": "Auto", + "white_balance_red": 6, + "white_balance_blue": 6, + } + nameless_card = _write_qr(tmp_path, json.dumps(payload)) + first, _ = recipe_operations.get_or_create_recipe_from_qr_card_and_backfill_name( + filepath=nameless_card, + ) + assert first.name == "" + + second, outcome = recipe_operations.get_or_create_recipe_from_qr_card_and_backfill_name( + filepath=_write_qr(tmp_path, json.dumps({**payload, "name": "Shared Name"})), + ) + + assert second.pk == first.pk + assert outcome is RecipeImportOutcome.NAME_BACKFILLED + first.refresh_from_db() + assert first.name == "Shared Name" + def test_preserves_existing_name_when_payload_has_no_name(self, tmp_path: Path) -> None: payload = { "v": 1, @@ -121,12 +147,12 @@ def test_preserves_existing_name_when_payload_has_no_name(self, tmp_path: Path) "white_balance_red": 5, "white_balance_blue": 5, } - first, _ = recipe_operations.get_or_create_recipe_from_qr_card( + first, _ = recipe_operations.get_or_create_recipe_from_qr_card_and_backfill_name( filepath=_write_qr(tmp_path, json.dumps(payload)), ) nameless_payload = {k: v for k, v in payload.items() if k != "name"} - second, _ = recipe_operations.get_or_create_recipe_from_qr_card( + second, _ = recipe_operations.get_or_create_recipe_from_qr_card_and_backfill_name( filepath=_write_qr(tmp_path, json.dumps(nameless_payload)), ) @@ -136,11 +162,11 @@ def test_preserves_existing_name_when_payload_has_no_name(self, tmp_path: Path) def test_raises_qr_not_found_for_image_without_qr(self) -> None: with pytest.raises(card_queries.QRCodeNotFoundError): - recipe_operations.get_or_create_recipe_from_qr_card(filepath=NON_CARD_IMAGE) + recipe_operations.get_or_create_recipe_from_qr_card_and_backfill_name(filepath=NON_CARD_IMAGE) def test_raises_invalid_payload_for_bad_qr_content(self, tmp_path: Path) -> None: # A QR that decodes but doesn't carry a valid recipe payload. bad_qr = _write_qr(tmp_path, json.dumps({"v": 1, "wrong_key": "wrong"})) with pytest.raises(card_queries.InvalidQRRecipePayloadError): - recipe_operations.get_or_create_recipe_from_qr_card(filepath=bad_qr) + recipe_operations.get_or_create_recipe_from_qr_card_and_backfill_name(filepath=bad_qr) diff --git a/tests/integration/domain/recipes/test_set_recipe_name.py b/tests/integration/domain/recipes/test_set_recipe_name.py index 568e770..da0c726 100644 --- a/tests/integration/domain/recipes/test_set_recipe_name.py +++ b/tests/integration/domain/recipes/test_set_recipe_name.py @@ -19,11 +19,11 @@ def test_only_updates_name_field(self): recipe.refresh_from_db() assert recipe.film_simulation == "Provia" - def test_publishes_recipe_image_updated_event(self, captured_logs): + def test_publishes_recipe_name_updated_event(self, captured_logs): recipe = FujifilmRecipeFactory(name="") set_recipe_name(recipe=recipe, name="My Recipe") - updated_events = [e for e in captured_logs if e.get("event_type") == events.RECIPE_IMAGE_UPDATED] + updated_events = [e for e in captured_logs if e.get("event_type") == events.RECIPE_NAME_UPDATED] assert len(updated_events) == 1 assert updated_events[0]["name"] == "My Recipe" assert updated_events[0]["recipe_id"] == recipe.pk @@ -32,5 +32,5 @@ def test_event_params_contain_name_and_recipe_id(self, captured_logs): recipe = FujifilmRecipeFactory(name="") set_recipe_name(recipe=recipe, name="Velvia Vivid") - updated_events = [e for e in captured_logs if e.get("event_type") == events.RECIPE_IMAGE_UPDATED] + updated_events = [e for e in captured_logs if e.get("event_type") == events.RECIPE_NAME_UPDATED] assert {"name", "recipe_id"} <= updated_events[0].keys() diff --git a/tests/unit/domain/recipes/test_get_recipe_data_from_qr_recipe.py b/tests/unit/domain/recipes/test_get_recipe_data_from_qr_recipe.py index c19d047..a3fca1e 100644 --- a/tests/unit/domain/recipes/test_get_recipe_data_from_qr_recipe.py +++ b/tests/unit/domain/recipes/test_get_recipe_data_from_qr_recipe.py @@ -1,3 +1,6 @@ +import pytest + +from src.domain.images import dataclasses as image_dataclasses from src.domain.recipes.cards import dataclasses as card_dataclasses from src.domain.recipes.cards import queries as card_queries @@ -28,7 +31,7 @@ def test_passes_through_required_string_and_int_fields(self) -> None: white_balance_blue=-1, ) - result = card_queries.get_recipe_data_from_qr_recipe(qr_recipe=qr) + result = card_queries.get_recipe_data_from_qr_recipe(qr_recipe=qr, image_path="card.jpg") assert result.film_simulation == "Classic Chrome" assert result.grain_roughness == "Weak" @@ -40,7 +43,7 @@ def test_passes_through_required_string_and_int_fields(self) -> None: def test_formats_decimal_zero_as_unsigned_string(self) -> None: qr = _valid_qr(highlight=0, shadow=0, color=0, sharpness=0, high_iso_nr=0, clarity=0) - result = card_queries.get_recipe_data_from_qr_recipe(qr_recipe=qr) + result = card_queries.get_recipe_data_from_qr_recipe(qr_recipe=qr, image_path="card.jpg") assert result.highlight == "0" assert result.shadow == "0" @@ -52,7 +55,7 @@ def test_formats_decimal_zero_as_unsigned_string(self) -> None: def test_formats_positive_decimal_with_plus_sign(self) -> None: qr = _valid_qr(highlight=2, sharpness=1) - result = card_queries.get_recipe_data_from_qr_recipe(qr_recipe=qr) + result = card_queries.get_recipe_data_from_qr_recipe(qr_recipe=qr, image_path="card.jpg") assert result.highlight == "+2" assert result.sharpness == "+1" @@ -60,7 +63,7 @@ def test_formats_positive_decimal_with_plus_sign(self) -> None: def test_formats_negative_decimal_without_extra_plus(self) -> None: qr = _valid_qr(shadow=-1, high_iso_nr=-4) - result = card_queries.get_recipe_data_from_qr_recipe(qr_recipe=qr) + result = card_queries.get_recipe_data_from_qr_recipe(qr_recipe=qr, image_path="card.jpg") assert result.shadow == "-1" assert result.high_iso_nr == "-4" @@ -68,7 +71,7 @@ def test_formats_negative_decimal_without_extra_plus(self) -> None: def test_formats_half_step_tone_decimals_as_signed_floats(self) -> None: qr = _valid_qr(highlight=1.5, shadow=-1.5) - result = card_queries.get_recipe_data_from_qr_recipe(qr_recipe=qr) + result = card_queries.get_recipe_data_from_qr_recipe(qr_recipe=qr, image_path="card.jpg") assert result.highlight == "+1.5" assert result.shadow == "-1.5" @@ -80,7 +83,7 @@ def test_formats_half_step_mono_color_decimals_as_signed_floats(self) -> None: monochromatic_color_magenta_green=0.5, ) - result = card_queries.get_recipe_data_from_qr_recipe(qr_recipe=qr) + result = card_queries.get_recipe_data_from_qr_recipe(qr_recipe=qr, image_path="card.jpg") assert result.monochromatic_color_warm_cool == "-2.5" assert result.monochromatic_color_magenta_green == "+0.5" @@ -89,7 +92,7 @@ def test_defaults_absent_decimal_fields_to_zero_string(self) -> None: # For a non-mono sim with DRP off, absent decimal fields get "0" defaults. qr = _valid_qr() - result = card_queries.get_recipe_data_from_qr_recipe(qr_recipe=qr) + result = card_queries.get_recipe_data_from_qr_recipe(qr_recipe=qr, image_path="card.jpg") assert result.highlight == "0" assert result.shadow == "0" @@ -100,21 +103,21 @@ def test_defaults_absent_decimal_fields_to_zero_string(self) -> None: def test_defaults_grain_size_to_none_when_roughness_is_off(self) -> None: qr = _valid_qr(grain_roughness="Off", grain_size=None) - result = card_queries.get_recipe_data_from_qr_recipe(qr_recipe=qr) + result = card_queries.get_recipe_data_from_qr_recipe(qr_recipe=qr, image_path="card.jpg") assert result.grain_size is None def test_preserves_grain_size_when_present(self) -> None: qr = _valid_qr(grain_roughness="Weak", grain_size="Small") - result = card_queries.get_recipe_data_from_qr_recipe(qr_recipe=qr) + result = card_queries.get_recipe_data_from_qr_recipe(qr_recipe=qr, image_path="card.jpg") assert result.grain_size == "Small" def test_defaults_colour_chrome_fields_to_off_when_absent(self) -> None: qr = _valid_qr(color_chrome_effect=None, color_chrome_fx_blue=None) - result = card_queries.get_recipe_data_from_qr_recipe(qr_recipe=qr) + result = card_queries.get_recipe_data_from_qr_recipe(qr_recipe=qr, image_path="card.jpg") assert result.color_chrome_effect == "Off" assert result.color_chrome_fx_blue == "Off" @@ -122,7 +125,7 @@ def test_defaults_colour_chrome_fields_to_off_when_absent(self) -> None: def test_preserves_colour_chrome_fields_when_present(self) -> None: qr = _valid_qr(color_chrome_effect="Strong", color_chrome_fx_blue="Weak") - result = card_queries.get_recipe_data_from_qr_recipe(qr_recipe=qr) + result = card_queries.get_recipe_data_from_qr_recipe(qr_recipe=qr, image_path="card.jpg") assert result.color_chrome_effect == "Strong" assert result.color_chrome_fx_blue == "Weak" @@ -130,21 +133,21 @@ def test_preserves_colour_chrome_fields_when_present(self) -> None: def test_defaults_name_to_empty_when_payload_omits_it(self) -> None: qr = _valid_qr() # name defaults to None on QRFujifilmRecipe - result = card_queries.get_recipe_data_from_qr_recipe(qr_recipe=qr) + result = card_queries.get_recipe_data_from_qr_recipe(qr_recipe=qr, image_path="card.jpg") assert result.name == "" def test_passes_name_through_when_payload_includes_it(self) -> None: qr = _valid_qr(name="My Summer Recipe") - result = card_queries.get_recipe_data_from_qr_recipe(qr_recipe=qr) + result = card_queries.get_recipe_data_from_qr_recipe(qr_recipe=qr, image_path="card.jpg") assert result.name == "My Summer Recipe" def test_nulls_drp_fields_when_drp_is_active(self) -> None: qr = _valid_qr(d_range_priority="Auto", dynamic_range="DR100", highlight=1, shadow=-1) - result = card_queries.get_recipe_data_from_qr_recipe(qr_recipe=qr) + result = card_queries.get_recipe_data_from_qr_recipe(qr_recipe=qr, image_path="card.jpg") assert result.dynamic_range is None assert result.highlight is None @@ -153,7 +156,7 @@ def test_nulls_drp_fields_when_drp_is_active(self) -> None: def test_nulls_mono_fields_for_colour_sim_when_present_in_qr(self) -> None: qr = _valid_qr(monochromatic_color_warm_cool=5.0, monochromatic_color_magenta_green=-3.0) - result = card_queries.get_recipe_data_from_qr_recipe(qr_recipe=qr) + result = card_queries.get_recipe_data_from_qr_recipe(qr_recipe=qr, image_path="card.jpg") assert result.monochromatic_color_warm_cool is None assert result.monochromatic_color_magenta_green is None @@ -168,14 +171,14 @@ def test_v1_payload_without_sensors_yields_empty_tuple(self) -> None: # is satisfied. qr = _valid_qr(v=1) - result = card_queries.get_recipe_data_from_qr_recipe(qr_recipe=qr) + result = card_queries.get_recipe_data_from_qr_recipe(qr_recipe=qr, image_path="card.jpg") assert result.sensors == () def test_v2_payload_with_sensors_round_trips(self) -> None: qr = _valid_qr(v=2, sensors=("X-Trans IV", "GFX")) - result = card_queries.get_recipe_data_from_qr_recipe(qr_recipe=qr) + result = card_queries.get_recipe_data_from_qr_recipe(qr_recipe=qr, image_path="card.jpg") assert result.sensors == ("X-Trans IV", "GFX") @@ -185,6 +188,43 @@ def test_v2_payload_without_sensors_yields_empty_tuple(self) -> None: # absent value to an empty tuple downstream. qr = _valid_qr(v=2) - result = card_queries.get_recipe_data_from_qr_recipe(qr_recipe=qr) + result = card_queries.get_recipe_data_from_qr_recipe(qr_recipe=qr, image_path="card.jpg") assert result.sensors == () + + +class TestGetRecipeDataFromQRRecipeInvalidValues: + """ + Values that are the right type but not legal. + + The payload type checks let these through, and the FujifilmRecipeData + validators reject them with a plain ValueError. They must surface as + InvalidQRRecipePayloadError so a caller importing a batch of cards can + record the offending card as failed and carry on with the rest. + """ + + def test_raises_for_a_name_longer_than_the_maximum(self) -> None: + qr = _valid_qr(name="x" * (image_dataclasses.RECIPE_NAME_MAX_LEN + 1)) + + with pytest.raises(card_queries.InvalidQRRecipePayloadError) as exc_info: + card_queries.get_recipe_data_from_qr_recipe(qr_recipe=qr, image_path="card.jpg") + + assert exc_info.value.reason == "invalid_field_value" + assert exc_info.value.image_path == "card.jpg" + + def test_raises_for_a_non_ascii_name(self) -> None: + qr = _valid_qr(name="Velvia Añejo") + + with pytest.raises(card_queries.InvalidQRRecipePayloadError) as exc_info: + card_queries.get_recipe_data_from_qr_recipe(qr_recipe=qr, image_path="card.jpg") + + assert exc_info.value.reason == "invalid_field_value" + + def test_raises_for_an_unknown_sensor_name(self) -> None: + # A card exported by a deployment that knows a sensor this one doesn't. + qr = _valid_qr(v=2, sensors=("X-Trans VI",)) + + with pytest.raises(card_queries.InvalidQRRecipePayloadError) as exc_info: + card_queries.get_recipe_data_from_qr_recipe(qr_recipe=qr, image_path="card.jpg") + + assert exc_info.value.reason == "invalid_field_value" diff --git a/tests/unit/domain/recipes/test_set_recipe_name.py b/tests/unit/domain/recipes/test_set_recipe_name.py index 59d0d6e..1658018 100644 --- a/tests/unit/domain/recipes/test_set_recipe_name.py +++ b/tests/unit/domain/recipes/test_set_recipe_name.py @@ -25,12 +25,12 @@ def test_recipe_not_saved_on_invalid_name(self): class TestSetRecipeNameEventPublishing: - def test_publishes_recipe_image_updated_event(self, captured_logs): + def test_publishes_recipe_name_updated_event(self, captured_logs): recipe = MagicMock() recipe.pk = 42 set_recipe_name(recipe=recipe, name="My Recipe") - updated_events = [e for e in captured_logs if e.get("event_type") == events.RECIPE_IMAGE_UPDATED] + updated_events = [e for e in captured_logs if e.get("event_type") == events.RECIPE_NAME_UPDATED] assert len(updated_events) == 1 assert updated_events[0]["name"] == "My Recipe" assert updated_events[0]["recipe_id"] == 42