"""Fixtures for unit tests.""" from dataclasses import dataclass import datetime from types import SimpleNamespace import pytest from src.logic import dlq_event from src.logic import source_event from src.logic import target_event from src.models.preference_change import InvalidPreferenceMessage from src.models.preference_change import PreferenceChangeEnvelope from src.models.preference_change import UpdatedSubscription @dataclass class HandlerFlowMocks: """Container with patched handler dependencies.""" parse: object flatten: object prepare: object send: object dlq_send: object @dataclass class EventProducerMocks: """Container with patched EventProducer context manager.""" cls: object context_manager: object producer: object string_serializer: object key_serializer: object value_serializer: object @pytest.fixture def sample_invalid_messages(): """Provide reusable invalid preference change messages.""" return [ InvalidPreferenceMessage( message_key='fan-123', message_value='{"id": "fan-123"}', error_type='ValidationError', error_message='missing field', ), InvalidPreferenceMessage( message_key=None, message_value='{"id": "fan-456"}', error_type='RuntimeError', error_message='unexpected failure', ), ] @pytest.fixture def dlq_settings(mocker): """Patch DLQ-related config settings for dlq_event tests.""" settings = SimpleNamespace( kafka_dlq_topic='fan-preferences.dlq', kafka_bootstrap_servers='broker-1:9092', kafka_security_protocol='SSL', ) mocker.patch.object(dlq_event.config, 'settings', settings) return settings @pytest.fixture def event_producer_mocks(mocker): """Patch EventProducer to capture interactions.""" producer = mocker.Mock(name='producer') context_manager = mocker.MagicMock(name='producer_cm') context_manager.__enter__.return_value = producer serializer_instances = [ mocker.Mock(name='key_serializer'), mocker.Mock(name='value_serializer'), ] string_serializer = mocker.patch.object(dlq_event, 'StringSerializer', side_effect=serializer_instances) mock_cls = mocker.patch.object(dlq_event, 'EventProducer', return_value=context_manager) return EventProducerMocks( cls=mock_cls, context_manager=context_manager, producer=producer, string_serializer=string_serializer, key_serializer=serializer_instances[0], value_serializer=serializer_instances[1], ) @pytest.fixture def raw_valid_event(): """Provide a raw AWS Kafka event for testing.""" return { 'eventSource': 'aws:kafka', 'eventSourceArn': ( 'arn:aws:kafka:us-east-1:285943604611:cluster/' 'qa-managed-kafka-fansifter/8a1e6cbc-c4ed-433e-aa4a-49348509b877-19' ), 'bootstrapServers': ( 'b-2.qamanagedkafkafansift.asokq6.c19.kafka.us-east-1.amazonaws.com:9094,' 'b-1.qamanagedkafkafansift.asokq6.c19.kafka.us-east-1.amazonaws.com:9094,' 'b-3.qamanagedkafkafansift.asokq6.c19.kafka.us-east-1.amazonaws.com:9094' ), 'records': { 'event.owsPreferenceCenter.fanSubscriptionsUpdate-0': [ { 'topic': 'event.owsPreferenceCenter.fanSubscriptionsUpdate', 'partition': 0, 'offset': 8, 'timestamp': 1762784792574, 'timestampType': 'CREATE_TIME', # Base64-encoded fan ID 'key': 'MDYzNGI2M2YtNWIxNi00YTBjLWE3OTAtNjQ5NzdkYzA2ZTVk', # Base64-encoded JSON with subscription preference changes 'value': ( 'ewogICJpZCI6ICIwNjM0YjYzZi01YjE2LTRhMGMtYTc5MC02NDk3N2RjMDZlNWQiLAogICJj' 'cm1JZCI6ICJhME9UeTAwMDAwTVFBVDFNQVAiLAogICJ1cGRhdGVkU3Vic2NyaXB0aW9ucyI6' 'IFsKICAgIHsKICAgICAgImlkIjogImVkNzY4MTFkLTg2YjEtNDliOC1hYTI3LTA3ZGIxZTFl' 'YWI4NyIsCiAgICAgICJjcm1JZCI6ICJhMFZUeTAwMDAwSDRKeEtNQVYiLAogICAgICAibWFp' 'bGluZ0xpc3RJZCI6ICIwOGFiYzlmYi1jNGFkLTQzNjMtOWQ3Yy04ZGRjN2UzY2E3NzgiLAog' 'ICAgICAibWFpbGluZ0xpc3RDcm1JZCI6ICJhMFM2MTAwMDAwMFpoR3lFQUsiLAogICAgICAi' 'ZW1haWxDYW1wYWlnbklkIjogImVkZWM3MDA4LTgxNDctNDZhYy1iMzQ4LThmYjQzMGRkM2Qz' 'ZSIsCiAgICAgICJvbGRWYWx1ZSI6IHRydWUsCiAgICAgICJuZXdWYWx1ZSI6IGZhbHNlCiAg' 'ICB9CiAgXSwKICAidXBkYXRlZEF0IjogIjIwMjUtMTEtMDZUMTQ6Mjg6NDUuNjA0NjEyWiIK' 'fQ==' ), 'headers': [], } ] } } @pytest.fixture def raw_invalid_event(raw_valid_event): """Provide a raw AWS Kafka event with an invalid payload (missing required fields).""" invalid_event = raw_valid_event.copy() records = invalid_event['records']['event.owsPreferenceCenter.fanSubscriptionsUpdate-0'] invalid_payload = { 'value': 'ewoJImZvbzEiOiAiYmFyIgp9', # {"foo1": "bar"} 'topic': records[0]['topic'], 'partition': records[0]['partition'], 'offset': records[0]['offset'], 'timestamp': records[0]['timestamp'], 'timestampType': records[0]['timestampType'], 'key': records[0]['key'], 'headers': [], } invalid_event['records'] = { 'event.owsPreferenceCenter.fanSubscriptionsUpdate-0': [invalid_payload] } return invalid_event @pytest.fixture def updated_subscriptions(): """Provide parsed preference change envelopes for testing.""" return [ PreferenceChangeEnvelope( id='0634b63f-5b16-4a0c-a790-64977dc06e5d', updated_at=datetime.datetime.fromisoformat('2025-11-06T14:28:45.604612+00:00'), updated_subscriptions=[ UpdatedSubscription( id='ed76811d-86b1-49b8-aa27-07db1e1eab87', crm_id='a0VTy00000H4JxKMAV', mailing_list_id='08abc9fb-c4ad-4363-9d7c-8ddc7e3ca778', mailing_list_crm_id='a0S61000000ZhGyEAK', email_campaign_id='edec7008-8147-46ac-b348-8fb430dd3d3e', old_value=True, new_value=False, ) ] ) ] @pytest.fixture def handler_event(): """Provide the minimal structure the Lambda handler expects.""" return {'records': {}} @pytest.fixture def handler_flow_mocks(mocker): """Patch all downstream dependencies used by the handler.""" parse = mocker.patch.object(source_event, 'parse') flatten = mocker.patch.object(source_event, 'flatten_events') prepare = mocker.patch.object(target_event, 'prepare_messages') send = mocker.patch.object(target_event, 'send_messages') dlq_send = mocker.patch.object(dlq_event, 'send_invalid_messages') return HandlerFlowMocks(parse=parse, flatten=flatten, prepare=prepare, send=send, dlq_send=dlq_send) @pytest.fixture def handler_success_mocks(handler_flow_mocks, sample_invalid_messages): """Configure handler dependency mocks for the happy path.""" handler_flow_mocks.parse.return_value = (['source-message'], sample_invalid_messages) handler_flow_mocks.flatten.return_value = ['flattened-subscriptions'] handler_flow_mocks.prepare.return_value = ['target-message'] return handler_flow_mocks @pytest.fixture def handler_error_mocks(handler_flow_mocks): """Configure handler dependency mocks to raise on parse.""" handler_flow_mocks.parse.side_effect = RuntimeError('source parsing failed') return handler_flow_mocks @pytest.fixture def duplicate_subscription_envelopes(): """Provide envelopes containing duplicate subscription ids with different timestamps.""" older = PreferenceChangeEnvelope( id='11111111-1111-1111-1111-111111111111', crm_id=None, updated_subscriptions=[ UpdatedSubscription( id='sub-1', crm_id=None, mailing_list_id='08abc9fb-c4ad-4363-9d7c-8ddc7e3ca778', mailing_list_crm_id=None, email_campaign_id='edec7008-8147-46ac-b348-8fb430dd3d3e', old_value=False, new_value=True, ) ], updated_at=datetime.datetime(2024, 1, 1, tzinfo=datetime.timezone.utc), ) newer = PreferenceChangeEnvelope( id='22222222-2222-2222-2222-222222222222', crm_id=None, updated_subscriptions=[ UpdatedSubscription( id='sub-1', crm_id=None, mailing_list_id='08abc9fb-c4ad-4363-9d7c-8ddc7e3ca778', mailing_list_crm_id=None, email_campaign_id='edec7008-8147-46ac-b348-8fb430dd3d3e', old_value=True, new_value=False, ) ], updated_at=datetime.datetime(2024, 2, 1, tzinfo=datetime.timezone.utc), ) return [older, newer]