"""Tests for product_in_store model.""" from datetime import datetime from datetime import timedelta from flexmock import flexmock from oto import response as oto_response from oto import status import pytest from availability import datastructures from availability.connectors import sql from availability.constants import field_const from availability.constants import models from availability.constants import stores from availability.logic import product_submission from availability.models import product as product_model from availability.models import product_in_store from availability.models import store as store_model from availability.models import task as task_model from tests.dbutils import db_setup_function as setup_function # noqa from tests.dbutils import db_teardown_function as teardown_function # noqa PRODUCT_ID = 123 UPC = '819224018247' ORCHARD_PRODUCT_ID = 819224018247 STORE_ID = stores.STORE_ID_ITUNES STORE_INTERNAL_ID = '1' STORE_INTERNAL_STATUS = 'Arbitrary Status' STATUS = models.RELEASE_STATUS_DELIVERED SALES_START_DATE = datetime.utcnow() GO_LIVE_DATE = datetime.utcnow() VALID_PRODUCT_IN_STORE_KWARGS = dict( store_id=STORE_ID, product_id=PRODUCT_ID, store_internal_id=STORE_INTERNAL_ID, store_internal_status=STORE_INTERNAL_STATUS, status=STATUS, sales_start_date=SALES_START_DATE, ) POLLING_DELAY_DAYS = 1 POLL_DAYS_AFTER_SALES = 1 def _get_product_in_store_kwargs(update_dict): result = VALID_PRODUCT_IN_STORE_KWARGS.copy() result.update(update_dict) return result @pytest.fixture def store_product_data(): """Return data to create ProductInStore.""" data = { 'product_id': 11111, 'store_id': 1, 'delivery_date': datetime(2017, 1, 1, 12, 0, 0), 'sales_start_date': datetime(2017, 1, 1, 12, 0, 0), 'store_internal_id': 'test id', 'store_internal_status': 'test status', } return data @pytest.fixture def test_store(test_database): """Create store entry for testing purposes.""" store = store_model.Store( store_id=1, name='test', polling_delay_days=POLLING_DELAY_DAYS, poll_days_after_sales=POLL_DAYS_AFTER_SALES) with sql.session_scope() as session: session.add(store) @pytest.mark.parametrize( 'countries, internal_representation', [ (['US', 'AR'], models.COUNTRIES_SEPARATOR.join(['AR', 'US'])), (['AR', 'US'], models.COUNTRIES_SEPARATOR.join(['AR', 'US'])), ([], ''), ]) def test_countries_property_setter(countries, internal_representation): """Test ProductInStore.countries setter with valid values.""" instance = product_in_store.ProductInStore(countries=countries) assert sorted(instance.countries) == sorted(countries) assert instance._countries == internal_representation def test_countries_property_getter(): """Test ProductInStore.countries getter.""" countries = ['US', 'AR', 'BE'] instance = product_in_store.ProductInStore() for countries_str in ( models.COUNTRIES_SEPARATOR.join(countries), models.COUNTRIES_SEPARATOR.join(reversed(countries))): # Assign to the private field to make sure ordering is applied in # getter if someone modifies DB directly. instance._countries = countries_str assert instance.countries == sorted(countries) def test_countries_property_str_value_assertion(): """Test ProductInStore.countries str value raises an AssertionError.""" instance = product_in_store.ProductInStore() with pytest.raises(AssertionError): instance.countries = 'US' @pytest.mark.parametrize( 'product_in_store_fields', [ {}, dict( store_id=STORE_ID+1, product_id=PRODUCT_ID+1, store_internal_id=STORE_INTERNAL_ID+'z', store_internal_status=STORE_INTERNAL_STATUS+'z', status=models.RELEASE_STATUS_READY_TO_GO_LIVE, sales_start_date=SALES_START_DATE+timedelta(days=1)), ]) def test_product_in_store_as_dict(product_in_store_fields): """Test product_in_store as_dict representation.""" optional_args = dict( countries=[], delivery_date=None, force_polling=None, go_live_date=None, product_in_store_id=None, received_for_polling_date=None) kwargs = _get_product_in_store_kwargs(product_in_store_fields) expected_result = kwargs.copy() expected_result.update(optional_args) instance = product_in_store.ProductInStore(**kwargs) assert instance.as_dict() == expected_result # Keep in mind that numbers below are valid for: # polling_delay_days=7, poll_days_after_sales=7 @pytest.mark.parametrize( ('store_id, status, received_date, sales_date, force_polling, ' 'task_status, optional_kwargs, expected_results, store_internal_id'), [ # Valid for polling, sales date is in the future (STORE_ID, models.RELEASE_STATUS_DELIVERED, datetime.utcnow() - timedelta(days=14), datetime.utcnow() + timedelta(days=14), False, models.TASK_STATUS_OK, {}, 1, ''), # Not valid for polling, as task status check is not disabled, # but the task is in failed state. (STORE_ID, models.RELEASE_STATUS_DELIVERED, datetime.utcnow() - timedelta(days=14), datetime.utcnow() + timedelta(days=14), False, models.TASK_STATUS_FAILED, {}, 0, ''), # Valid for polling, as task status check is disabled, # regardless the fact the task is in failed state. (STORE_ID, models.RELEASE_STATUS_DELIVERED, datetime.utcnow() - timedelta(days=14), datetime.utcnow() + timedelta(days=14), False, models.TASK_STATUS_FAILED, {'any_task_status': True}, 1, ''), # Valid for polling, sales date is in the past but less than # Store.poll_days_after_sales (STORE_ID, models.RELEASE_STATUS_DELIVERED, datetime.utcnow() - timedelta(days=14), datetime.utcnow() - timedelta(days=1), False, models.TASK_STATUS_OK, {}, 1, ''), # Valid for polling, sales date is in the past, # but force_polling is True (STORE_ID, models.RELEASE_STATUS_DELIVERED, datetime.utcnow() - timedelta(days=1), datetime.utcnow() - timedelta(days=14), True, models.TASK_STATUS_OK, {}, 1, ''), # Not valid for polling because of unknown store_id (STORE_ID+1, models.RELEASE_STATUS_DELIVERED, datetime.utcnow() - timedelta(days=14), datetime.utcnow() + timedelta(days=14), False, models.TASK_STATUS_OK, {}, 0, ''), # Not valid for polling because of status (STORE_ID, models.RELEASE_STATUS_INGESTION_FAILED, datetime.utcnow() - timedelta(days=14), datetime.utcnow() + timedelta(days=14), False, models.TASK_STATUS_OK, {}, 0, ''), # Not valid for polling because received date is less than # Store.polling_delay_days (STORE_ID, models.RELEASE_STATUS_DELIVERED, datetime.utcnow() - timedelta(days=1), datetime.utcnow() + timedelta(days=14), False, models.TASK_STATUS_OK, {}, 0, ''), # Not valid for polling, because # sales date + Store.poll_days_after_sales is in the past (STORE_ID, models.RELEASE_STATUS_DELIVERED, datetime.utcnow() - timedelta(days=14), datetime.utcnow() - timedelta(days=14), False, models.TASK_STATUS_OK, {}, 0, ''), # Valid for polling because of current_date_override value, even though # (sales date + Store.poll_days_after_sales) is in the past. (STORE_ID, models.RELEASE_STATUS_DELIVERED, datetime.utcnow() - timedelta(days=14), datetime.utcnow() - timedelta(days=8), False, models.TASK_STATUS_OK, {'current_date_override': datetime.utcnow() - timedelta(days=2)}, 1, ''), # Not valid for polling because # (received_for_polling_date + Store.polling_delay_days) is less # then the current_date_override. (STORE_ID, models.RELEASE_STATUS_DELIVERED, datetime.utcnow() - timedelta(days=6), datetime.utcnow() - timedelta(days=8), False, models.TASK_STATUS_OK, {'current_date_override': datetime.utcnow() - timedelta(days=2)}, 0, ''), # Not valid for polling because current_date_override value is in the # future and (sales date + Store.poll_days_after_sales) is in the past. (STORE_ID, models.RELEASE_STATUS_DELIVERED, datetime.utcnow() - timedelta(days=14), datetime.utcnow() - timedelta(days=8), False, models.TASK_STATUS_OK, {'current_date_override': datetime.utcnow() + timedelta(days=2)}, 0, ''), # Not valid for polling, because even with forced_polling set to True, # received_date + Store.poll_days_after_sales is in the past (STORE_ID, models.RELEASE_STATUS_DELIVERED, datetime.utcnow() - timedelta(days=14), datetime.utcnow() + timedelta(days=14), True, models.TASK_STATUS_OK, {}, 0, ''), # Valid for polling: forced_polling is set to True, and # current_date_override # received_date + Store.poll_days_after_sales is in the past (STORE_ID, models.RELEASE_STATUS_DELIVERED, datetime.utcnow() - timedelta(days=8), datetime.utcnow() + timedelta(days=1400), True, models.TASK_STATUS_OK, {'current_date_override': datetime.utcnow() - timedelta(days=2)}, 1, ''), # Not Valid for polling: store_internal_id is non-empty (STORE_ID, models.RELEASE_STATUS_DELIVERED, datetime.utcnow() - timedelta(days=8), datetime.utcnow() + timedelta(days=1400), True, models.TASK_STATUS_OK, {'current_date_override': datetime.utcnow() - timedelta(days=2)}, 0, STORE_INTERNAL_ID), ] ) def test_get_products_to_poll_query( store_id, status, received_date, sales_date, force_polling, task_status, optional_kwargs, expected_results, store_internal_id): """Test that get_products_to_poll_query returns only suitable values.""" countries = sorted(['US', 'CA']) store_obj = store_model.Store( store_id=STORE_ID, name='test store', polling_delay_days=7, poll_days_after_sales=7) product_obj = product_model.Product( product_id=PRODUCT_ID, orchard_product_id=ORCHARD_PRODUCT_ID, upc=UPC, itunes_vendor_id='42', provider=models.ORCHARD) product_in_store_obj = product_in_store.ProductInStore( store_id=STORE_ID, product_id=PRODUCT_ID, store_internal_id=store_internal_id, force_polling=force_polling, status=status, store_internal_status=STORE_INTERNAL_STATUS, received_for_polling_date=received_date, countries=countries, delivery_date=datetime.utcnow(), sales_start_date=sales_date,) with sql.session_scope() as session: session.add(store_obj) session.add(product_obj) session.add(product_in_store_obj) task_obj = task_model.Task( product_in_store_id=product_in_store_obj.product_in_store_id, status=task_status) with sql.session_scope() as session: session.add(task_obj) result = product_in_store.get_products_to_poll_query( store_id, **optional_kwargs) assert result.count() == expected_results for row in result: data = dict(zip(row.keys(), row)) assert data['store_id'] == store_obj.store_id assert data['orchard_product_id'] == product_obj.orchard_product_id assert data['upc'] == product_obj.upc assert (data['product_in_store_id'] == product_in_store_obj.product_in_store_id) assert data['task_id'] == task_obj.task_id assert data['countries'] == models.COUNTRIES_SEPARATOR.join(countries) @pytest.mark.parametrize( 'keys, values, expected_num_results', [ (['a', 'b'], ['1', '2'], 1), ((), (), 0), ] ) def test_products_to_poll(keys, values, expected_num_results): """Test products_to_poll yields expected values from query.""" fake_result = flexmock( keys=lambda: keys, __iter__=lambda x: values) fake_result_item = [fake_result] fake_query_iterable = fake_result_item * expected_num_results (flexmock(product_in_store) .should_receive('get_products_to_poll_query') .with_args(STORE_ID) .and_return(fake_query_iterable).once()) result = product_in_store.products_to_poll(STORE_ID) result_list = list(result) assert len(result_list) == expected_num_results for value, expected_value in zip(result_list, fake_query_iterable): assert sorted(value.keys()) == sorted(keys) assert sorted(value.values()) == sorted(values) @pytest.mark.parametrize( ('initial_release_id', 'received_release_id', 'result_release_id'), ( ('a', 'a', 'a'), ('', '', ''), ('a', 'b', 'a'), # existing release should remain the same ('a', '', 'a'), ('', 'a', 'a'), # empty release id chould be updated ) ) def test_update_release_status_for_product_in_store( test_store, initial_release_id, received_release_id, result_release_id): """Assert can update release_status for given ProductInStore.""" product_in_store_obj = product_in_store.ProductInStore( store_id=STORE_ID, product_id=PRODUCT_ID, store_internal_id=initial_release_id, store_internal_status=STORE_INTERNAL_STATUS, status=STATUS, sales_start_date=SALES_START_DATE) with sql.session_scope() as session: session.add(product_in_store_obj) release_data = { 'store_release_id': received_release_id, 'store_release_status': 'test_status', 'db_release_status': models.RELEASE_STATUS_LIVE, 'live_countries': ['UK', 'USA'] } release_status = datastructures.ReleaseStatus(**release_data) response_update = product_in_store.update_release_status( product_in_store_obj.product_in_store_id, product_in_store_obj.store_id, release_status) assert isinstance(response_update, oto_response.Response) assert response_update.status == status.NO_CONTENT with sql.session_scope() as session: saved_product = session.query(product_in_store.ProductInStore).first() assert saved_product.status == release_data['db_release_status'] assert saved_product.countries == release_data['live_countries'] assert ( saved_product.store_internal_status == release_data['store_release_status']) assert saved_product.store_internal_id == result_release_id @pytest.mark.parametrize( ['old_date', 'status', 'assertion'], [ (None, models.RELEASE_STATUS_INGESTING, lambda gld: gld is None), (None, models.RELEASE_STATUS_LIVE, lambda gld: gld is not None and gld <= datetime.utcnow()), (GO_LIVE_DATE, models.RELEASE_STATUS_LIVE, lambda gld: gld == GO_LIVE_DATE), ] ) def test_update_release_status_sets_go_live_date_once( old_date, status, assertion, test_database, mocker): """Test that go_live_date is is set only once.""" mocker.patch( 'availability.models.product_in_store.ttl_exceeded', return_value=False) product_in_store_obj = product_in_store.ProductInStore( go_live_date=old_date, **VALID_PRODUCT_IN_STORE_KWARGS) with sql.session_scope() as session: session.add(product_in_store_obj) release_data = { 'store_release_id': 'test_release_id', 'store_release_status': 'test_status', 'db_release_status': status, 'live_countries': ['US'] } release_status = datastructures.ReleaseStatus(**release_data) product_in_store.update_release_status( product_in_store_obj.product_in_store_id, product_in_store_obj.store_id, release_status) with sql.session_scope() as session: product_in_store_obj = session.query( product_in_store.ProductInStore).first() assert assertion(product_in_store_obj.go_live_date) def test_exists_in_store_with_not_existing_product( test_database, products_data): """Assert can determine non-existent product.""" itunes_product = products_data[0] product_submission.submit_to_poll([itunes_product]) assert product_in_store.exists_in_store( product_id=1, store_id=stores.STORE_ID_SPOTIFY) is False def test_exists_in_store_with_existing_product(test_database, products_data): """Assert can determine existing product.""" itunes_product = products_data[0] product_submission.submit_to_poll([itunes_product]) assert product_in_store.exists_in_store( product_id=1, store_id=stores.STORE_ID_ITUNES) is True def test_list_products(test_database, products_data): """Assert can get products in store by list of product_ids.""" product_submission.submit_to_poll(products_data) product_id = 1 expected_keys = [ field_const.PRODUCT_ID, field_const.STORE_ID, field_const.STATUS, field_const.COUNTRIES, field_const.GO_LIVE_DATE, field_const.STORE_INTERNAL_ID] expected_countries = sorted(products_data[0]['countries']) # Set first store's product countries list to non-empty value. with sql.session_scope() as session: product = session.query(product_in_store.ProductInStore).first() setattr(product, field_const.COUNTRIES, expected_countries) session.add(product) response = product_in_store.list_store_statuses_for_products([product_id]) assert isinstance(response, oto_response.Response) assert response.status == status.OK assert len(response.message) == 2 for product_dict in response.message: assert isinstance(product_dict, dict) assert sorted(product_dict.keys()) == sorted(expected_keys) product_with_countries, product_without_countries = response.message assert sorted(product_with_countries['countries']) == expected_countries assert sorted(product_without_countries['countries']) == [] def test_list_products_handles_not_found(test_database): """Assert proper response returned if product not found.""" response = product_in_store.list_store_statuses_for_products([100, 500]) assert isinstance(response, oto_response.Response) assert response.status == status.NOT_FOUND def test_list_products_converts_go_live_date_to_string( test_database, products_data): """Assert go_live_date is converted to string representation.""" product_submission.submit_to_poll(products_data) product_ids = [d[field_const.PRODUCT_ID] for d in products_data] # Set first store's product go live date to non-empty value. with sql.session_scope() as session: product = session.query(product_in_store.ProductInStore).first() setattr(product, field_const.GO_LIVE_DATE, datetime.utcnow()) session.add(product) response = product_in_store.list_store_statuses_for_products(product_ids) product_with_date, product_without_date = response.message assert product_without_date[field_const.GO_LIVE_DATE] is None assert isinstance(product_with_date[field_const.GO_LIVE_DATE], str) def test_list_products_normalizes_non_datetime_go_live_date(mocker): """Assert non-datetime go_live_date values are normalized to None.""" # Simulate what SQLAlchemy/pymysql hands back for a legacy # '0000-00-00 00:00:00' row: a raw string rather than a datetime. fake_product = mocker.Mock( product_id=1, store_id=100, status='live', countries=[], store_internal_id='abc', go_live_date='0000-00-00 00:00:00', ) mock_session = mocker.MagicMock() mock_session.query().filter().all.return_value = [fake_product] mocker.patch.object( product_in_store.sql, 'session_scope', return_value=mocker.MagicMock( __enter__=mocker.Mock(return_value=mock_session), __exit__=mocker.Mock(return_value=False), ), ) response = product_in_store.list_store_statuses_for_products([1]) assert response.message[0][field_const.GO_LIVE_DATE] is None def test_ttl_not_exceeded_without_force_polling( test_database, test_store, store_product_data): """Assert exceeded TTL calculated based on sales_start_date.""" store_product_data['sales_start_date'] = datetime.utcnow() store_product = product_in_store.ProductInStore(**store_product_data) assert product_in_store.ttl_exceeded(store_product) is False def test_ttl_not_exceeded_with_force_polling( test_database, test_store, store_product_data): """Assert exceeded TTL calculated based on received_for_polling_date.""" store_product_data['received_for_polling_date'] = datetime.utcnow() store_product_data['force_polling'] = True store_product = product_in_store.ProductInStore(**store_product_data) assert product_in_store.ttl_exceeded(store_product) is False def test_ttl_exceeded_without_force_polling( test_database, test_store, store_product_data): """Assert ttl_exceeded without force_polling is False.""" store_product = product_in_store.ProductInStore(**store_product_data) assert product_in_store.ttl_exceeded(store_product) is True def test_ttl_exceeded_with_force_polling( test_database, test_store, store_product_data): """Assert ttl_exceeded with force_polling is False.""" store_product_data['received_for_polling_date'] = ( datetime.utcnow() - timedelta(days=POLL_DAYS_AFTER_SALES + 1)) store_product_data['force_polling'] = True store_product = product_in_store.ProductInStore(**store_product_data) assert product_in_store.ttl_exceeded(store_product) is True @pytest.mark.parametrize( ['release_status', 'expected_value'], [(models.RELEASE_STATUS_LIVE, False), ('any status', True)] ) def test_release_not_live(release_status, expected_value): """Assert can determine whether release is live.""" release_data = {'db_release_status': release_status} assert product_in_store.release_not_live(release_data) is expected_value def test_update_release_status_with_exceeded_ttl(test_store): """Assert status will be correctly set for product with exceeded ttl.""" exceeded_ttl_date = datetime.utcnow() - timedelta( days=POLL_DAYS_AFTER_SALES + 1) product_in_store_obj = product_in_store.ProductInStore( store_id=STORE_ID, product_id=PRODUCT_ID, store_internal_id=STORE_INTERNAL_ID, store_internal_status=STORE_INTERNAL_STATUS, status=STATUS, sales_start_date=exceeded_ttl_date) with sql.session_scope() as session: session.add(product_in_store_obj) release_status = datastructures.ReleaseStatus( db_release_status=models.RELEASE_STATUS_INGESTING, store_release_id='1', store_release_status='test status') update_response = product_in_store.update_release_status( product_in_store_obj.product_in_store_id, product_in_store_obj.store_id, release_status) assert update_response.status == status.NO_CONTENT with sql.session_scope() as session: store_product = session.query(product_in_store.ProductInStore).first() assert store_product.status == models.RELEASE_STATUS_INGESTION_FAILED def test_update_release_status_with_force_poll_and_exceeded_ttl(test_store): """Assert status set correctly when force polling with exceeded ttl.""" exceeded_ttl_date = datetime.utcnow() - timedelta( days=POLL_DAYS_AFTER_SALES + 1) product_in_store_obj = product_in_store.ProductInStore( store_id=STORE_ID, product_id=PRODUCT_ID, store_internal_id=STORE_INTERNAL_ID, store_internal_status=STORE_INTERNAL_STATUS, status=STATUS, sales_start_date=SALES_START_DATE, force_polling=True, received_for_polling_date=exceeded_ttl_date) with sql.session_scope() as session: session.add(product_in_store_obj) release_status = datastructures.ReleaseStatus( db_release_status=models.RELEASE_STATUS_INGESTING, store_release_id='1', store_release_status='test status') update_response = product_in_store.update_release_status( product_in_store_obj.product_in_store_id, product_in_store_obj.store_id, release_status) assert update_response.status == status.NO_CONTENT with sql.session_scope() as session: store_product = session.query(product_in_store.ProductInStore).first() assert store_product.status == models.RELEASE_STATUS_INGESTION_FAILED # TODO: while this mirrors current behaviour, we should consider # not going through the product update in database, if response from store # gave us no internal ID or status information. # Related ticket: VC-1276 def test_update_release_status_with_empty_fields(test_store): """Assert status set correctly when some optional fields are missing.""" product_in_store_obj = product_in_store.ProductInStore( store_id=STORE_ID, product_id=PRODUCT_ID, store_internal_id=STORE_INTERNAL_ID, store_internal_status=STORE_INTERNAL_STATUS, status=STATUS, sales_start_date=SALES_START_DATE) with sql.session_scope() as session: session.add(product_in_store_obj) release_status = datastructures.ReleaseStatus( db_release_status=models.RELEASE_STATUS_INGESTING) update_response = product_in_store.update_release_status( product_in_store_obj.product_in_store_id, product_in_store_obj.store_id, release_status) assert update_response.status == status.NO_CONTENT with sql.session_scope() as session: store_product = session.query(product_in_store.ProductInStore).first() assert store_product.status == models.RELEASE_STATUS_INGESTING assert store_product.store_internal_status == '' assert store_product.store_internal_id == STORE_INTERNAL_ID