diff --git a/.openapi-generator/custom_templates/model_templates/method_set_attribute.mustache b/.openapi-generator/custom_templates/model_templates/method_set_attribute.mustache new file mode 100644 index 000000000..4d8516b3d --- /dev/null +++ b/.openapi-generator/custom_templates/model_templates/method_set_attribute.mustache @@ -0,0 +1,63 @@ + def set_attribute(self, name, value): + # this is only used to set properties on self + + path_to_item = [] + if self._path_to_item: + path_to_item.extend(self._path_to_item) + path_to_item.append(name) + + if name in self.openapi_types: + # Widened from upstream: for a composed (oneOf/anyOf) model the + # generator flattens the members' properties into the parent but + # keeps only the last member's type, so the parent alone rejects + # payloads valid for every other member. See composed_union_types. + required_types_mixed = ( + composed_union_types(type(self), name) or self.openapi_types[name] + ) + elif self.additional_properties_type is None: + raise ApiAttributeError( + "{0} has no attribute '{1}'".format( + type(self).__name__, name), + path_to_item + ) + elif self.additional_properties_type is not None: + required_types_mixed = self.additional_properties_type + + if get_simple_class(name) != str: + error_msg = type_error_message( + var_name=name, + var_value=name, + valid_classes=(str,), + key_type=True + ) + raise ApiTypeError( + error_msg, + path_to_item=path_to_item, + valid_classes=(str,), + key_type=True + ) + + if self._check_type: + value = validate_and_convert_types( + value, required_types_mixed, path_to_item, self._spec_property_naming, + self._check_type, configuration=self._configuration) + # Widened from upstream for the same reason as required_types_mixed + # above: the flattened parent keeps only the last member's enum. + allowed_values = ( + composed_union_allowed_values(type(self), name) + or self.allowed_values.get((name,)) + ) + if allowed_values: + check_allowed_values( + {(name,): allowed_values}, + (name,), + value + ) + if (name,) in self.validations: + check_validations( + self.validations, + (name,), + value, + self._configuration + ) + self.__dict__['_data_store'][name] = value \ No newline at end of file diff --git a/.openapi-generator/custom_templates/model_utils.mustache b/.openapi-generator/custom_templates/model_utils.mustache index 84c13035b..223a4c55b 100644 --- a/.openapi-generator/custom_templates/model_utils.mustache +++ b/.openapi-generator/custom_templates/model_utils.mustache @@ -111,6 +111,59 @@ def composed_model_input_classes(cls): return [] +def composed_oneof_members(cls): + """The oneOf/anyOf member classes of a composed model, () for other models.""" + composed = getattr(cls, '_composed_schemas', None) + if not composed: + return () + return tuple(composed.get('oneOf') or ()) + tuple(composed.get('anyOf') or ()) + + +def composed_union_types(cls, name): + """Union of the types the oneOf/anyOf members declare for property `name`. + + openapi-generator flattens the oneOf members' properties into the composed + parent, but for a property that several members declare it keeps only the + last member's type. The parent then rejects payloads that are valid for + every other member - e.g. an LLM provider config whose `auth` is typed as + `OpenAiProviderAuth` alone cannot carry Bedrock or Azure Foundry auth. + + Widening to the union loses no validation: the value is still checked + against the composed schemas themselves by validate_get_composed_info. + + Returns () when no member declares `name`, so the caller keeps the + parent's own type. + + A member is not necessarily a model: `oneOf: [$ref, {type: string}]` emits + `'oneOf': [Thing, str]`, and a primitive has no `openapi_types`, hence the + getattr fallback rather than a direct attribute read. + """ + types = [] + for member in composed_oneof_members(cls): + for member_type in getattr(member, 'openapi_types', {}).get(name, ()): + if member_type not in types: + types.append(member_type) + return tuple(types) + + +def composed_union_allowed_values(cls, name): + """Union of the enum values the oneOf/anyOf members allow for `name`. + + Same generator defect as composed_union_types: the flattened parent keeps + only the last member's enum, so a discriminator-like `type` property ends + up accepting exactly one of the variants and which one depends on the order + of the oneOf array in the OpenAPI document. + + Returns {} when no member constrains `name`, so the caller falls back to + the parent's own allowed_values. Tolerates non-model members for the same + reason as composed_union_types. + """ + merged = {} + for member in composed_oneof_members(cls): + merged.update(getattr(member, 'allowed_values', {}).get((name,), {})) + return merged + + class OpenApiModel(object): """The base class for all OpenAPIModels""" @@ -1139,6 +1192,7 @@ def attempt_convert_item(input_value, valid_classes, path_to_item, if configuration is None or not configuration.discard_unknown_keys: raise get_type_error(input_value, path_to_item, valid_classes, key_type=key_type) + last_conversion_exc = None for valid_class in valid_classes_coercible: try: if issubclass(valid_class, OpenApiModel): @@ -1150,11 +1204,14 @@ def attempt_convert_item(input_value, valid_classes, path_to_item, return deserialize_primitive(input_value, valid_class, path_to_item) except (ApiTypeError, ApiValueError, ApiKeyError) as conversion_exc: - if must_convert: - raise conversion_exc - # if we have conversion errors when must_convert == False - # we ignore the exception and move on to the next class + # Upstream re-raises immediately when must_convert is True, which + # gives a property whose type came from a oneOf/anyOf union only + # one attempt: the first candidate class. Try them all and report + # the last failure only if none matched. + last_conversion_exc = conversion_exc continue + if must_convert and last_conversion_exc is not None: + raise last_conversion_exc # we were unable to convert, must_convert == False return input_value diff --git a/gooddata-api-client/gooddata_api_client/model_utils.py b/gooddata-api-client/gooddata_api_client/model_utils.py index 78d2b33f0..8340bc084 100644 --- a/gooddata-api-client/gooddata_api_client/model_utils.py +++ b/gooddata-api-client/gooddata_api_client/model_utils.py @@ -120,6 +120,59 @@ def composed_model_input_classes(cls): return [] +def composed_oneof_members(cls): + """The oneOf/anyOf member classes of a composed model, () for other models.""" + composed = getattr(cls, '_composed_schemas', None) + if not composed: + return () + return tuple(composed.get('oneOf') or ()) + tuple(composed.get('anyOf') or ()) + + +def composed_union_types(cls, name): + """Union of the types the oneOf/anyOf members declare for property `name`. + + openapi-generator flattens the oneOf members' properties into the composed + parent, but for a property that several members declare it keeps only the + last member's type. The parent then rejects payloads that are valid for + every other member - e.g. an LLM provider config whose `auth` is typed as + `OpenAiProviderAuth` alone cannot carry Bedrock or Azure Foundry auth. + + Widening to the union loses no validation: the value is still checked + against the composed schemas themselves by validate_get_composed_info. + + Returns () when no member declares `name`, so the caller keeps the + parent's own type. + + A member is not necessarily a model: `oneOf: [$ref, {type: string}]` emits + `'oneOf': [Thing, str]`, and a primitive has no `openapi_types`, hence the + getattr fallback rather than a direct attribute read. + """ + types = [] + for member in composed_oneof_members(cls): + for member_type in getattr(member, 'openapi_types', {}).get(name, ()): + if member_type not in types: + types.append(member_type) + return tuple(types) + + +def composed_union_allowed_values(cls, name): + """Union of the enum values the oneOf/anyOf members allow for `name`. + + Same generator defect as composed_union_types: the flattened parent keeps + only the last member's enum, so a discriminator-like `type` property ends + up accepting exactly one of the variants and which one depends on the order + of the oneOf array in the OpenAPI document. + + Returns {} when no member constrains `name`, so the caller falls back to + the parent's own allowed_values. Tolerates non-model members for the same + reason as composed_union_types. + """ + merged = {} + for member in composed_oneof_members(cls): + merged.update(getattr(member, 'allowed_values', {}).get((name,), {})) + return merged + + class OpenApiModel(object): """The base class for all OpenAPIModels""" @@ -132,7 +185,13 @@ def set_attribute(self, name, value): path_to_item.append(name) if name in self.openapi_types: - required_types_mixed = self.openapi_types[name] + # Widened from upstream: for a composed (oneOf/anyOf) model the + # generator flattens the members' properties into the parent but + # keeps only the last member's type, so the parent alone rejects + # payloads valid for every other member. See composed_union_types. + required_types_mixed = ( + composed_union_types(type(self), name) or self.openapi_types[name] + ) elif self.additional_properties_type is None: raise ApiAttributeError( "{0} has no attribute '{1}'".format( @@ -160,9 +219,15 @@ def set_attribute(self, name, value): value = validate_and_convert_types( value, required_types_mixed, path_to_item, self._spec_property_naming, self._check_type, configuration=self._configuration) - if (name,) in self.allowed_values: + # Widened from upstream for the same reason as required_types_mixed + # above: the flattened parent keeps only the last member's enum. + allowed_values = ( + composed_union_allowed_values(type(self), name) + or self.allowed_values.get((name,)) + ) + if allowed_values: check_allowed_values( - self.allowed_values, + {(name,): allowed_values}, (name,), value ) @@ -1469,6 +1534,7 @@ def attempt_convert_item(input_value, valid_classes, path_to_item, if configuration is None or not configuration.discard_unknown_keys: raise get_type_error(input_value, path_to_item, valid_classes, key_type=key_type) + last_conversion_exc = None for valid_class in valid_classes_coercible: try: if issubclass(valid_class, OpenApiModel): @@ -1480,11 +1546,14 @@ def attempt_convert_item(input_value, valid_classes, path_to_item, return deserialize_primitive(input_value, valid_class, path_to_item) except (ApiTypeError, ApiValueError, ApiKeyError) as conversion_exc: - if must_convert: - raise conversion_exc - # if we have conversion errors when must_convert == False - # we ignore the exception and move on to the next class + # Upstream re-raises immediately when must_convert is True, which + # gives a property whose type came from a oneOf/anyOf union only + # one attempt: the first candidate class. Try them all and report + # the last failure only if none matched. + last_conversion_exc = conversion_exc continue + if must_convert and last_conversion_exc is not None: + raise last_conversion_exc # we were unable to convert, must_convert == False return input_value diff --git a/packages/gooddata-sdk/src/gooddata_sdk/__init__.py b/packages/gooddata-sdk/src/gooddata_sdk/__init__.py index a58a592d0..f33e24a5a 100644 --- a/packages/gooddata-sdk/src/gooddata_sdk/__init__.py +++ b/packages/gooddata-sdk/src/gooddata_sdk/__init__.py @@ -140,6 +140,10 @@ ) from gooddata_sdk.catalog.organization.layout.notification_channel import ( CatalogDeclarativeNotificationChannel, + CatalogDefaultSmtp, + CatalogInPlatform, + CatalogNotificationChannelDestination, + CatalogSmtp, CatalogWebhook, ) from gooddata_sdk.catalog.organization.service import ( diff --git a/packages/gooddata-sdk/src/gooddata_sdk/catalog/organization/layout/notification_channel.py b/packages/gooddata-sdk/src/gooddata_sdk/catalog/organization/layout/notification_channel.py index c7adbade4..b1233d627 100644 --- a/packages/gooddata-sdk/src/gooddata_sdk/catalog/organization/layout/notification_channel.py +++ b/packages/gooddata-sdk/src/gooddata_sdk/catalog/organization/layout/notification_channel.py @@ -1,53 +1,25 @@ # (C) 2024 GoodData Corporation +from __future__ import annotations + import builtins +from typing import Any, Union from attrs import define, field +from attrs import fields as attrs_fields from gooddata_api_client.model.declarative_notification_channel import DeclarativeNotificationChannel -from gooddata_api_client.model.notification_channel_destination import NotificationChannelDestination +from gooddata_api_client.model.default_smtp import DefaultSmtp +from gooddata_api_client.model.in_platform import InPlatform +from gooddata_api_client.model.smtp import Smtp from gooddata_api_client.model.webhook import Webhook from gooddata_sdk.catalog.base import Base - -# The generator collapses the `type` enums of NotificationChannelDestination's oneOf children -# into the last child's single value (IN_PLATFORM), so valid destinations of the other types -# fail client-side validation on both serialization and response parsing. Restore the full set. -NotificationChannelDestination.allowed_values[("type",)].update( - { - "WEBHOOK": "WEBHOOK", - "SMTP": "SMTP", - "DEFAULT_SMTP": "DEFAULT_SMTP", - "IN_PLATFORM": "IN_PLATFORM", - } -) - -# TODO: there is an issue with generated client which causes these two classes to fail -# type in gooddata_api_client/model/declarative_notification_channel_destination.py contains only WEBHOOK as valid value -# @define(kw_only=True) -# class CatalogDefaultSmtp(Base): -# from_email: Optional[str] = None -# from_email_name: Optional[str] = None -# -# @staticmethod -# def client_class() -> Type[DefaultSmtp]: -# return DefaultSmtp -# -# -# @define(kw_only=True) -# class CatalogSmtp(Base): -# from_email: Optional[str] = None -# from_email_name: Optional[str] = None -# host: Optional[str] = None -# password: Optional[str] = None -# port: Optional[int] = None -# username: Optional[str] = None -# -# @staticmethod -# def client_class() -> Type[Smtp]: -# return Smtp +from gooddata_sdk.utils import safeget @define(kw_only=True) class CatalogWebhook(Base): + """Webhook destination for notifications.""" + type: str = field(default="WEBHOOK", init=False) url: str token: str | None = field(default=None, eq=False) @@ -58,6 +30,85 @@ def client_class() -> builtins.type[Webhook]: return Webhook +@define(kw_only=True) +class CatalogSmtp(Base): + """Custom SMTP destination for notifications. + + `host`, `port`, `username` and `password` are required by the API on create + and update, but are optional here because reads never return the password. + """ + + type: str = field(default="SMTP", init=False) + from_email: str | None = None + from_email_name: str | None = None + host: str | None = None + port: int | None = None + username: str | None = None + password: str | None = field(default=None, eq=False) + + @staticmethod + def client_class() -> builtins.type[Smtp]: + return Smtp + + +@define(kw_only=True) +class CatalogDefaultSmtp(Base): + """Default SMTP destination for notifications - the platform's own mail server.""" + + type: str = field(default="DEFAULT_SMTP", init=False) + from_email: str | None = None + from_email_name: str | None = None + + @staticmethod + def client_class() -> builtins.type[DefaultSmtp]: + return DefaultSmtp + + +@define(kw_only=True) +class CatalogInPlatform(Base): + """In-platform destination for notifications.""" + + type: str = field(default="IN_PLATFORM", init=False) + + @staticmethod + def client_class() -> builtins.type[InPlatform]: + return InPlatform + + +CatalogNotificationChannelDestination = Union[ + CatalogWebhook, + CatalogSmtp, + CatalogDefaultSmtp, + CatalogInPlatform, +] + +_DESTINATION_BY_TYPE: dict[str, builtins.type[CatalogNotificationChannelDestination]] = { + "WEBHOOK": CatalogWebhook, + "SMTP": CatalogSmtp, + "DEFAULT_SMTP": CatalogDefaultSmtp, + "IN_PLATFORM": CatalogInPlatform, +} + + +def _destination_from_api(data: dict[str, Any]) -> CatalogNotificationChannelDestination: + """Build the right destination class from a `destination` payload. + + Dispatched on `type` explicitly rather than left to cattrs: the four + destination classes have no uniquely-required field to disambiguate a + union on (`IN_PLATFORM` carries nothing but its type). + """ + destination_type = safeget(data, ["type"]) + destination_class = _DESTINATION_BY_TYPE.get(destination_type) + if destination_class is None: + raise ValueError(f"Unknown notification channel destination type: {destination_type}") + # Keep only what the class can take: `type` is init=False (implied by the + # class itself) and the API sends fields we do not model - `has_secret_key` + # on a webhook, for one. Silently ignoring those matches how cattrs used to + # structure this and keeps reads working when the API grows a field. + accepted = {f.name for f in attrs_fields(destination_class) if f.init} + return destination_class(**{k: v for k, v in data.items() if k in accepted}) + + @define(kw_only=True) class CatalogDeclarativeNotificationChannel(Base): id: str @@ -66,9 +117,22 @@ class CatalogDeclarativeNotificationChannel(Base): destination_type: str | None = None custom_dashboard_url: str | None = None allowed_recipients: str | None = None - # destination: Optional[Union[CatalogDefaultSmtp, CatalogSmtp, CatalogWebhook]] = None - destination: CatalogWebhook | None = None + destination: CatalogNotificationChannelDestination | None = None @staticmethod def client_class() -> builtins.type[DeclarativeNotificationChannel]: return DeclarativeNotificationChannel + + @classmethod + def from_api(cls, entity: dict[str, Any]) -> CatalogDeclarativeNotificationChannel: + data = entity if isinstance(entity, dict) else entity.to_dict(camel_case=False) + raw_destination = safeget(data, ["destination"]) + return cls( + id=data["id"], + name=safeget(data, ["name"]), + description=safeget(data, ["description"]), + destination_type=safeget(data, ["destination_type"]), + custom_dashboard_url=safeget(data, ["custom_dashboard_url"]), + allowed_recipients=safeget(data, ["allowed_recipients"]), + destination=_destination_from_api(raw_destination) if raw_destination is not None else None, + ) diff --git a/packages/gooddata-sdk/tests/catalog/unit_tests/test_composed_oneof_unions.py b/packages/gooddata-sdk/tests/catalog/unit_tests/test_composed_oneof_unions.py new file mode 100644 index 000000000..7e7a05897 --- /dev/null +++ b/packages/gooddata-sdk/tests/catalog/unit_tests/test_composed_oneof_unions.py @@ -0,0 +1,179 @@ +# (C) 2026 GoodData Corporation +"""Regression tests for oneOf/anyOf unions in the generated api client. + +openapi-generator's `python-prior` generator flattens a `oneOf`'s members into +the composed parent model, but for a property that several members declare it +keeps only the **last** member's value: + +- `allowed_values[('type',)]` ends up holding one member's enum, so the parent + accepts exactly one variant - and which one depends on the order of the + `oneOf` array in the OpenAPI document. +- `openapi_types[prop]` ends up holding one member's class, so a nested union + such as an LLM provider's `auth` cannot carry the other members' payloads. + +`model_utils` (via the `model_utils.mustache` / `method_set_attribute.mustache` +custom templates) widens both back to the union of the members. These tests pin +that behaviour for the LLM provider config - the instance this was reported +against - so a template or generator change cannot silently reintroduce it. + +The defect was repo-wide (25 collapsed enums and 47 collapsed types across 161 +composed models), so it is worth suspecting whenever a `oneOf` variant is +inexplicably rejected. +""" + +from __future__ import annotations + +import pytest +from gooddata_api_client.model.json_api_llm_provider_in_attributes_provider_config import ( + JsonApiLlmProviderInAttributesProviderConfig, +) +from gooddata_api_client.model_utils import ( + composed_union_allowed_values, + composed_union_types, +) +from gooddata_sdk import ( + CatalogAwsBedrockProviderConfig, + CatalogAzureFoundryApiKeyAuth, + CatalogAzureFoundryProviderConfig, + CatalogBedrockAccessKeyAuth, + CatalogLlmProvider, + CatalogLlmProviderModel, + CatalogOpenAiApiKeyAuth, + CatalogOpenAiProviderConfig, +) + +PROVIDER_CONFIGS = { + "OPENAI": ( + CatalogOpenAiProviderConfig( + auth=CatalogOpenAiApiKeyAuth(api_key="dummy"), + base_url="https://api.openai.com/v1", + organization="org-1", + ), + { + "type": "OPENAI", + "base_url": "https://api.openai.com/v1", + "organization": "org-1", + "auth": {"type": "API_KEY", "api_key": "dummy"}, + }, + ), + "AWS_BEDROCK": ( + CatalogAwsBedrockProviderConfig( + auth=CatalogBedrockAccessKeyAuth(access_key_id="akid", secret_access_key="secret", session_token="token"), + region="us-east-1", + ), + { + "type": "AWS_BEDROCK", + "region": "us-east-1", + "auth": { + "type": "ACCESS_KEY", + "access_key_id": "akid", + "secret_access_key": "secret", + "session_token": "token", + }, + }, + ), + "AZURE_FOUNDRY": ( + CatalogAzureFoundryProviderConfig( + auth=CatalogAzureFoundryApiKeyAuth(api_key="dummy"), + endpoint="https://example.openai.azure.com", + ), + { + "type": "AZURE_FOUNDRY", + "endpoint": "https://example.openai.azure.com", + "auth": {"type": "API_KEY", "api_key": "dummy"}, + }, + ), +} + + +@pytest.mark.parametrize("provider_type", sorted(PROVIDER_CONFIGS)) +def test_llm_provider_to_api_accepts_every_provider_config(provider_type: str) -> None: + """Every provider variant must survive `to_api()`, not just the flattened one.""" + provider_config, expected = PROVIDER_CONFIGS[provider_type] + provider = CatalogLlmProvider.init( + id=f"test-{provider_type.lower()}", + models=[CatalogLlmProviderModel(id="model-1", family="OPENAI")], + provider_config=provider_config, + name=f"Test {provider_type}", + default_model_id="model-1", + ) + + api_object = provider.to_api() + + assert api_object.to_dict()["attributes"]["provider_config"] == expected + + +@pytest.mark.parametrize("provider_type", sorted(PROVIDER_CONFIGS)) +def test_llm_provider_round_trips_through_from_api(provider_type: str) -> None: + provider_config, _ = PROVIDER_CONFIGS[provider_type] + provider = CatalogLlmProvider.init( + id=f"test-{provider_type.lower()}", + models=[CatalogLlmProviderModel(id="model-1", family="OPENAI")], + provider_config=provider_config, + name=f"Test {provider_type}", + default_model_id="model-1", + ) + + restored = CatalogLlmProvider.from_api(provider.to_dict()) + + assert restored.attributes is not None + assert restored.attributes.provider_config is not None + assert restored.attributes.provider_config.type == provider_type + + +def _oneof_members(model): + return model._composed_schemas["oneOf"] + + +def test_provider_config_type_enum_is_the_union_of_its_members() -> None: + """The parent's effective enum covers every member, not just the flattened one. + + Asserted against the members themselves rather than a hardcoded list, so + adding a provider to the OpenAPI document does not need a test edit. + """ + model = JsonApiLlmProviderInAttributesProviderConfig + per_member = {frozenset(member.allowed_values.get(("type",), {})) for member in _oneof_members(model)} + expected = frozenset().union(*per_member) + + effective = frozenset(composed_union_allowed_values(model, "type")) + + assert effective == expected + # Each member contributes its own single value, so a union of more than one + # is what makes this meaningful - and the generator wrote only one of them + # onto the parent. + assert len(expected) > 1 + assert frozenset(model.allowed_values.get(("type",), {})) < effective + + +def test_provider_config_auth_accepts_every_members_auth_class() -> None: + """`auth` is a nested union; the flattened parent kept only one auth class.""" + model = JsonApiLlmProviderInAttributesProviderConfig + expected = {t for member in _oneof_members(model) for t in member.openapi_types.get("auth", ())} + + effective = set(composed_union_types(model, "auth")) + + assert effective == expected + assert len(expected) > 1 + assert set(model.openapi_types.get("auth", ())) < effective + + +@pytest.mark.parametrize( + "union_helper", + [composed_union_types, composed_union_allowed_values], + ids=["types", "allowed_values"], +) +def test_union_helpers_tolerate_a_non_model_member(union_helper, monkeypatch) -> None: + """A composed member is not always a model, and primitives have no metadata. + + `oneOf: [$ref, {type: string}]` makes the generator emit + `'oneOf': [Thing, str]`. Reading `openapi_types` / `allowed_values` straight + off such a member raises AttributeError, which would break every attribute + assignment on that model rather than just the union widening. + """ + model = JsonApiLlmProviderInAttributesProviderConfig + with_primitive = dict(model._composed_schemas) + with_primitive["oneOf"] = tuple(with_primitive["oneOf"]) + (str,) + monkeypatch.setattr(model, "_composed_schemas", with_primitive) + + # Must not raise, and must still report what the real members declare. + assert union_helper(model, "type") diff --git a/packages/gooddata-sdk/tests/catalog/unit_tests/test_notification_channel_destinations.py b/packages/gooddata-sdk/tests/catalog/unit_tests/test_notification_channel_destinations.py new file mode 100644 index 000000000..5f06bf157 --- /dev/null +++ b/packages/gooddata-sdk/tests/catalog/unit_tests/test_notification_channel_destinations.py @@ -0,0 +1,133 @@ +# (C) 2026 GoodData Corporation +"""Tests for the notification channel destination union. + +`CatalogSmtp` / `CatalogDefaultSmtp` used to be commented out in the SDK with a +TODO pointing at the generated client: the composed destination model accepted a +single `type` only, so `to_api()` raised `ApiValueError` for every other +destination. That was the same oneOf-flattening defect covered by +`test_composed_oneof_unions.py`; with it fixed in `model_utils`, all four +destinations are usable and `NotificationChannelDestination.allowed_values` no +longer has to be monkeypatched at import time. +""" + +from __future__ import annotations + +import pytest +from gooddata_api_client.model.json_api_notification_channel_in_attributes_destination import ( + JsonApiNotificationChannelInAttributesDestination, +) +from gooddata_api_client.model.notification_channel_destination import NotificationChannelDestination +from gooddata_sdk import ( + CatalogDeclarativeNotificationChannel, + CatalogDefaultSmtp, + CatalogInPlatform, + CatalogSmtp, + CatalogWebhook, +) + +DESTINATIONS = { + "WEBHOOK": ( + CatalogWebhook(url="https://webhook.site/hook", token="secret"), + {"type": "WEBHOOK", "url": "https://webhook.site/hook", "token": "secret"}, + ), + "SMTP": ( + CatalogSmtp( + from_email="sender@example.com", + from_email_name="Sender", + host="smtp.example.com", + port=587, + username="user", + password="secret", + ), + { + "type": "SMTP", + "from_email": "sender@example.com", + "from_email_name": "Sender", + "host": "smtp.example.com", + "port": 587, + "username": "user", + "password": "secret", + }, + ), + "DEFAULT_SMTP": ( + CatalogDefaultSmtp(from_email="sender@example.com", from_email_name="Sender"), + {"type": "DEFAULT_SMTP", "from_email": "sender@example.com", "from_email_name": "Sender"}, + ), + "IN_PLATFORM": (CatalogInPlatform(), {"type": "IN_PLATFORM"}), +} + + +@pytest.mark.parametrize("destination_type", sorted(DESTINATIONS)) +def test_destination_to_api(destination_type: str) -> None: + """Every destination must survive `to_api()`, not just WEBHOOK.""" + destination, expected = DESTINATIONS[destination_type] + + assert destination.to_api().to_dict() == expected + + +@pytest.mark.parametrize("destination_type", sorted(DESTINATIONS)) +def test_notification_channel_to_api_carries_the_destination(destination_type: str) -> None: + destination, expected = DESTINATIONS[destination_type] + channel = CatalogDeclarativeNotificationChannel( + id=f"channel-{destination_type.lower()}", + name=f"Channel {destination_type}", + destination=destination, + allowed_recipients="CREATOR", + ) + + api_object = channel.to_api() + + assert api_object.to_dict()["destination"] == expected + + +@pytest.mark.parametrize("destination_type", sorted(DESTINATIONS)) +def test_notification_channel_round_trips(destination_type: str) -> None: + """`from_api` must rebuild the concrete destination class, not a webhook.""" + destination, _ = DESTINATIONS[destination_type] + channel = CatalogDeclarativeNotificationChannel( + id=f"channel-{destination_type.lower()}", + name=f"Channel {destination_type}", + destination=destination, + custom_dashboard_url="https://dashboard.site", + allowed_recipients="CREATOR", + ) + + restored = CatalogDeclarativeNotificationChannel.from_api(channel.to_dict(camel_case=False)) + + assert restored == channel + assert type(restored.destination) is type(destination) + assert restored.destination is not None + assert restored.destination.type == destination_type + + +def test_from_api_rejects_an_unknown_destination_type() -> None: + with pytest.raises(ValueError, match="Unknown notification channel destination type: TELEPATHY"): + CatalogDeclarativeNotificationChannel.from_api({"id": "channel", "destination": {"type": "TELEPATHY"}}) + + +@pytest.mark.parametrize( + "model", + [NotificationChannelDestination, JsonApiNotificationChannelInAttributesDestination], + ids=["shared", "json_api"], +) +@pytest.mark.parametrize("destination_type", sorted(DESTINATIONS)) +def test_generated_destination_models_accept_every_type(model, destination_type: str) -> None: + """Pin the generated composed models too - this is where the defect lived.""" + _, payload = DESTINATIONS[destination_type] + + destination = model(**payload) + + assert destination.type == destination_type + + +def test_destination_model_class_attribute_is_still_collapsed() -> None: + """Guard the reason the monkeypatch could go: the fix is at validation time. + + The generator still writes a single-variant enum onto the composed parent - + nothing regenerates that away - so this documents that the models above pass + because `model_utils` unions the members, not because the class attribute + got fixed. + """ + collapsed = NotificationChannelDestination.allowed_values.get(("type",), {}) + + assert len(collapsed) == 1