diff --git a/connect/cli/plugins/commerce/commands.py b/connect/cli/plugins/commerce/commands.py index 7349ba8..0ba3592 100644 --- a/connect/cli/plugins/commerce/commands.py +++ b/connect/cli/plugins/commerce/commands.py @@ -10,8 +10,11 @@ from connect.cli.core.config import pass_config from connect.cli.core.terminal import console from connect.cli.plugins.commerce.utils import ( + clone_stream, display_streams_table, export_stream, + get_destination_account, + print_results, ) @@ -102,5 +105,65 @@ def cmd_export_stream(config, stream_id, output_file, output_path): ) +@grp_commerce_streams.command( + name='clone', + short_help='Create a clone of a stream.', +) +@click.argument('source_stream_id', metavar='stream_id', nargs=1, required=True) # noqa: E304 +@click.option( + '--destination_account', + '-d', + 'destination_account', + help='Destination account ID', +) +@click.option( + '--new-stream-name', + '-n', + 'name', + help='Cloned stream name', +) +@click.option( + '--validate', + '-v', + is_flag=True, + help='Executes the validate action after the clone.', + default=False, +) +@pass_config +def cmd_clone_stream( + config, + source_stream_id, + destination_account, + name, + validate, +): + destination_account_instance = get_destination_account(config, destination_account) + + console.confirm( + 'Are you sure you want to Clone ' f'the stream {source_stream_id} ?', + abort=True, + ) + console.echo('') + + stream_id, results = clone_stream( + origin_account=config.active, + stream_id=source_stream_id, + destination_account=destination_account_instance, + stream_name=name, + validate=validate, + ) + + console.echo('') + + console.secho( + f'Stream {source_stream_id} cloned properly to {stream_id}.', + fg='green', + ) + + console.echo('') + + print_results(results) + + def get_group(): return grp_commerce diff --git a/connect/cli/plugins/commerce/utils.py b/connect/cli/plugins/commerce/utils.py index 732c8c7..44e61db 100644 --- a/connect/cli/plugins/commerce/utils.py +++ b/connect/cli/plugins/commerce/utils.py @@ -3,6 +3,8 @@ from datetime import datetime import string from urllib.parse import urlparse +import tempfile +from mimetypes import guess_type from click import ClickException from connect.cli.core.terminal import console @@ -351,3 +353,411 @@ def export_stream( f'Stream {stream_id} exported properly to {output_file}.', fg='green', ) + + +def get_destination_account( + config, + destination_account_id, +): + if not destination_account_id or destination_account_id == config.active.id: + return config.active + if destination_account_id in config.accounts: + return config.accounts[destination_account_id] + else: + raise ClickException(f'Error obtaining the destination account id {destination_account_id}') + + +def create_stream_from_origin( + client, + origin_stream, + collection, + stream_name=None, + validate_context_objects=False, +): + context = origin_stream['context'] + if validate_context_objects: + for obj, ns in ( + ('product', 'products'), + ('marketplace', 'marketplaces'), + ('account', 'accounts'), + ('pricelist', 'pricelists'), + ): + if obj in context: + if client.collection(ns).filter(id=context[obj]['id']).count() == 0: + console.secho( + f'The {obj} {context[obj]["id"]} does not exists.', + fg='yellow', + ) + del context[obj] + body = { + 'name': ( + stream_name + or f'Clone of {origin_stream["name"]} {origin_stream["id"]}) {datetime.now().strftime("%d/%m/%Y %H:%M:%S")}' + ), + 'description': origin_stream.get('description', ''), + 'context': context, + 'sources': origin_stream['sources'], + 'status': 'configuring', + 'type': collection, + 'visibility': 'private', + } + try: + destination_stream = client.ns(collection).streams.create( + json=body, + ) + except ClientError as e: + raise ClickException(e.errors[0]) + return destination_stream['id'] + + +def _upload_file( + client, + folder, + stream_id, + file, +): + with open(file, 'rb') as f: + name = f.name + data = { + 'file': (name, f, guess_type(name)), + } + return ( + client.ns( + 'media', + ) + .ns( + 'folders', + ) + .collection( + folder, + )[stream_id] + .files.create( + files=data, + ) + ) + + +def upload_sample( + client, + collection, + stream_id, + file, +): + response = _upload_file(client, 'streams_samples', stream_id, file) + body = {'samples': {'input': {'id': response['id']}}} + client.ns(collection).streams[stream_id].update( + json=body, + ) + return response['id'] + + +def clone_sample( + origin_client, + destination_client, + destination_stream_id, + collection, + origin_stream_input_sample, + progress, +): + task_sample = progress.add_task('Processing input sample', total=1) + with tempfile.TemporaryDirectory() as tmpdir: + sample_path = os.path.join(tmpdir, 'sample') + os.makedirs(sample_path) + name = urlparse(origin_stream_input_sample['name']).path.split('/')[-1] + stream = urlparse(origin_stream_input_sample['name']).path.split('/')[-4] + destination = os.path.join(sample_path, name) + _download_file( + origin_client, + 'streams_samples', + stream, + origin_stream_input_sample['id'], + destination, + ) + upload_sample( + destination_client, + collection, + destination_stream_id, + destination, + ) + progress.update(task_sample, completed=1) + + +def upload_attachment( + client, + stream_id, + attachment, +): + return _upload_file(client, 'streams_attachments', stream_id, attachment) + + +def clone_attachments( + origin_client, + destination_client, + attachments, + stream_id, + destination_stream_id, + progress, +): + file_mapping = {} + task_attachment = progress.add_task('Processing attachments', total=len(attachments)) + with tempfile.TemporaryDirectory() as tmpdir: + for attachment in attachments: + destination = os.path.join( + tmpdir, + attachment['name'], + ) + _download_file( + client=origin_client, + folder_type=attachment['folder']['type'], + folder_name=attachment['folder']['name'], + file_id=attachment['id'], + file_destination=destination, + ) + response = upload_attachment( + destination_client, + destination_stream_id, + destination, + ) + file_mapping[ + f'/public/v1/media/folders/streams_attachments/{stream_id}/files/{attachment["id"]}' + ] = f'/public/v1/media/folders/streams_attachments/{destination_stream_id}/files/{response["id"]}' + progress.update(task_attachment, advance=1) + return file_mapping + + +def _sort_list_by_id(list_to_sort): + return sorted(list_to_sort, key=lambda c: c['id']) + + +def generate_column_mapping(client, collection, stream_id): + mapping_by_name = {} + columns_by_id = {} + columns = _sort_list_by_id(list(client.ns(collection).streams[stream_id].columns.all())) + for col in columns: + if col['name'] in mapping_by_name: + mapping_by_name[col['name']].append(col['id']) + else: + mapping_by_name[col['name']] = [col['id']] + columns_by_id[col['id']] = col + return mapping_by_name, columns_by_id + + +def clone_transformations( + destination_client, + destination_stream_id, + collection, + file_mapping, + transformations, + o_mapping_by_name, + progress, +): + transformation_task = progress.add_task( + 'Processing transformations', total=len(transformations) + ) + for transformation in transformations: + d_mapping_by_name, columns_by_id = generate_column_mapping( + destination_client, + collection, + destination_stream_id, + ) + input_columns = [] + for col in transformation['columns']['input']: + column_ids = d_mapping_by_name[col['name']] + position = 0 + if len(column_ids) > 1: + for id in o_mapping_by_name[col['name']]: + if id == col['id']: + break + position += 1 + input_column_id = column_ids[position] + input_columns.append(columns_by_id[input_column_id]) + payload = { + 'description': transformation.get('description', ''), + 'function': {'id': transformation['function']['id']}, + 'settings': transformation['settings'], + 'overview': transformation['overview'], + 'position': transformation['position'], + 'columns': { + 'input': input_columns, + 'output': transformation['columns']['output'], + }, + } + + if transformation['function']['name'] == 'Lookup Data from a stream attached Excel': + if 'file' in payload['settings']: + payload['settings']['file'] = file_mapping[payload['settings']['file']] + destination_client.ns( + collection, + ).streams[destination_stream_id].transformations.create( + payload=payload, + ) + progress.update(transformation_task, advance=1) + + +def align_column_output( + collection, origin_client, origin_stream_id, destination_client, destination_stream_id, progress +): + origin_columns = _sort_list_by_id( + list(origin_client.ns(collection).streams[origin_stream_id].columns.all()) + ) + columns_task = progress.add_task('Processing columns', total=len(origin_columns)) + dest_columns = _sort_list_by_id( + list(destination_client.ns(collection).streams[destination_stream_id].columns.all()) + ) + updated = 0 + for n in range(len(origin_columns)): + if origin_columns[n]['output'] is False and dest_columns[n]['output'] is True: + destination_client.ns(collection).streams[destination_stream_id].columns[ + dest_columns[n]['id'] + ].update( + payload={'output': False}, + ) + updated += 1 + progress.update(columns_task, advance=1) + return len(origin_columns), updated + + +def print_results(results): + COLUMNS = ( + 'Module', + ('right', 'Processed'), + ('right', 'Created'), + ('right', 'Updated'), + ('right', 'Deleted'), + ('right', 'Skipped'), + ('right', 'Errors'), + ) + console.table( + columns=COLUMNS, + rows=results, + expand=True, + ) + + +def clone_stream( + origin_account, + stream_id, + destination_account, + stream_name=None, + validate=False, +): + results = [] + + with console.progress() as progress: + collection = guess_if_billing_or_pricing_stream(origin_account.client, stream_id) + if not collection: + raise ClickException( + f'Stream {stream_id} not found for the current account {origin_account.id}.' + ) + + origin_stream = ( + origin_account.client.ns(collection) + .streams.filter(id=stream_id) + .select( + 'context', + 'samples', + 'sources', + ) + .first() + ) + + stream_type = ( + 'Computed' if 'sources' in origin_stream and origin_stream['sources'] else 'Simple' + ) + + if stream_type == 'Computed' and origin_account != destination_account: + raise ClickException('You cannot clone a Computed stream between different accounts.') + + category = 'Inbound' if origin_stream['owner']['id'] != origin_account.id else 'Outbound' + + if category == 'Inbound': + raise ClickException('Inbound streams cannot be cloned.') + + destination_stream_id = create_stream_from_origin( + destination_account.client, + origin_stream, + collection, + stream_name, + origin_account != destination_account, + ) + + file_mapping = {} + if ( + stream_type == 'Simple' + and 'samples' in origin_stream + and 'input' in origin_stream['samples'] + ): + clone_sample( + origin_account.client, + destination_account.client, + destination_stream_id, + collection, + origin_stream['samples']['input'], + progress, + ) + results.append(('Sample input', 1, 1, 0, 0, 0, 0)) + + attachments = list( + origin_account.client.ns('media') + .ns('folders') + .collection('streams_attachments')[stream_id] + .files.all() + ) + if attachments: + file_mapping = clone_attachments( + origin_account.client, + destination_account.client, + attachments, + stream_id, + destination_stream_id, + progress, + ) + results.append(('Attachments', len(attachments), len(attachments), 0, 0, 0, 0)) + + transformations = list( + origin_account.client.ns( + collection, + ) + .streams[stream_id] + .transformations.all() + .select( + 'columns', + ) + ) + + if transformations: + o_mapping_by_name, _ = generate_column_mapping( + origin_account.client, collection, stream_id + ) + clone_transformations( + destination_account.client, + destination_stream_id, + collection, + file_mapping, + transformations, + o_mapping_by_name, + progress, + ) + results.append( + ('Transformations', len(transformations), len(transformations), 0, 0, 0, 0) + ) + + processed, updated = align_column_output( + collection, + origin_account.client, + stream_id, + destination_account.client, + destination_stream_id, + progress, + ) + results.append(('Columns', processed, 0, updated, 0, 0, 0)) + + if validate: + console.secho('') + destination_account.client.ns(collection).streams[destination_stream_id]('validate').post() + console.secho( + f'Stream {destination_stream_id} validation executed properly.', + fg='green', + ) + + return destination_stream_id, results diff --git a/tests/fixtures/commerce/stream_retrieve_response.json b/tests/fixtures/commerce/stream_retrieve_response.json index 249f5c1..a7911ca 100644 --- a/tests/fixtures/commerce/stream_retrieve_response.json +++ b/tests/fixtures/commerce/stream_retrieve_response.json @@ -16,7 +16,7 @@ } }, "owner": { - "id": "VA-050-000", + "id": "VA-000", "name": "Vendor account 00" }, "type": "pricing", diff --git a/tests/plugins/commerce/test_commands.py b/tests/plugins/commerce/test_commands.py index 57d3e54..8849fcd 100644 --- a/tests/plugins/commerce/test_commands.py +++ b/tests/plugins/commerce/test_commands.py @@ -1,6 +1,7 @@ import json import tempfile import os +from copy import copy, deepcopy import pytest from click.testing import CliRunner @@ -286,3 +287,536 @@ def test_export_stream_transformations_client_error(mocker, ccli, mocked_respons ) assert result.exit_code == 0 + + +@pytest.mark.parametrize('validate', (True, False)) +@pytest.mark.parametrize('stream_type', ('Computed', 'Simple')) +def test_clone_stream( + mocker, + ccli, + mocked_responses, + config_mocker, + stream_type, + validate, +): + mocked_responses.add( + method='GET', + url='https://localhost/public/v1/billing/streams?eq(id,STR-7755-7115-2464)&limit=0&offset=0', + json={}, + headers={ + 'Content-Range': 'items 0-0/0', + }, + ) + mocked_responses.add( + method='GET', + url='https://localhost/public/v1/pricing/streams?eq(id,STR-7755-7115-2464)&limit=0&offset=0', + json={}, + headers={ + 'Content-Range': 'items 0-1/1', + }, + ) + with open('./tests/fixtures/commerce/stream_retrieve_response.json') as content: + response = json.load(content)[0] + if stream_type == 'Computed': + del response['samples'] + response['sources'] = {'something': 'else'} + mocked_responses.add( + method='GET', + url='https://localhost/public/v1/pricing/streams?eq(id,STR-7755-7115-2464)&select(context,samples,sources)&limit=1&offset=0', + json=[response], + ) + mocked_responses.add( + method='POST', + url='https://localhost/public/v1/pricing/streams', + json={'id': 'STR-4444-5555-6666'}, + ) + if stream_type == 'Simple': + mocked_responses.add( + method='GET', + url='https://localhost/public/v1/media/folders/streams_samples/STR-7755-7115-2464/files/MFL-9059-7665-2037', + body=b'somecontent', + ) + mocked_responses.add( + method='POST', + url='https://localhost/public/v1/media/folders/streams_samples/STR-4444-5555-6666/files', + json={'id': 'MFL-9059-7665-333'}, + ) + mocked_responses.add( + method='PUT', + url='https://localhost/public/v1/pricing/streams/STR-4444-5555-6666', + json={'samples': {'input': {'id': 'MFL-9059-7665-333'}}}, + ) + + with open('./tests/fixtures/commerce/attachments_response.json') as content: + mocked_responses.add( + method='GET', + url='https://localhost/public/v1/media/folders/streams_attachments/STR-7755-7115-2464/files?limit=100&offset=0', + json=json.load(content), + ) + mocked_responses.add( + method='GET', + url='https://localhost/public/v1/media/folders/streams_attachments/STR-7755-7115-2464/files/MFL-2481-0572-9001', + body=b'somecontent', + ) + mocked_responses.add( + method='POST', + url='https://localhost/public/v1/media/folders/streams_attachments/STR-4444-5555-6666/files', + json={'id': 'MFL-9059-7665-444'}, + ) + mocked_responses.add( + method='GET', + url='https://localhost/public/v1/media/folders/streams_attachments/STR-7755-7115-2464/files/MFL-8784-6884-8192', + body=b'somecontent', + ) + mocked_responses.add( + method='POST', + url='https://localhost/public/v1/media/folders/streams_attachments/STR-4444-5555-6666/files', + json={'id': 'MFL-9059-7665-555'}, + ) + + with open('./tests/fixtures/commerce/columns_response.json') as content: + data = json.load(content) + last_column = data[-1] + last_column['id'] = 'SCOL-7755-7115-2464-011' + last_column['position'] = 110000 + data.append(last_column) + mocked_responses.add( + method='GET', + url='https://localhost/public/v1/pricing/streams/STR-7755-7115-2464/columns?limit=100&offset=0', + json=data, + ) + + with open('./tests/fixtures/commerce/transformations_response.json') as content: + mocked_responses.add( + method='GET', + url='https://localhost/public/v1/pricing/streams/STR-7755-7115-2464/transformations?limit=100&offset=0', + json=json.load(content), + ) + + with open('./tests/fixtures/commerce/columns_response.json') as content: + mocked_responses.add( + method='GET', + url='https://localhost/public/v1/pricing/streams/STR-4444-5555-6666/columns?limit=100&offset=0', + json=json.load(content), + ) + + mocked_responses.add( + method='POST', + url='https://localhost/public/v1/pricing/streams/STR-4444-5555-6666/transformations', + ) + + with open('./tests/fixtures/commerce/columns_response.json') as content: + data = json.load(content) + last_column = data[-1] + last_column['id'] = 'SCOL-7755-7115-2464-011' + last_column['position'] = 110000 + data.append(last_column) + mocked_responses.add( + method='GET', + url='https://localhost/public/v1/pricing/streams/STR-4444-5555-6666/columns?limit=100&offset=0', + json=data, + ) + mocked_responses.add( + method='POST', + url='https://localhost/public/v1/pricing/streams/STR-4444-5555-6666/transformations', + ) + + with open('./tests/fixtures/commerce/columns_response.json') as content: + data = json.load(content) + updated_data = deepcopy(data) + updated_data[9]['output'] = False + mocked_responses.add( + method='GET', + url='https://localhost/public/v1/pricing/streams/STR-7755-7115-2464/columns?limit=100&offset=0', + json=updated_data, + ) + mocked_responses.add( + method='GET', + url='https://localhost/public/v1/pricing/streams/STR-4444-5555-6666/columns?limit=100&offset=0', + json=data, + ) + + mocked_responses.add( + method='PUT', + url='https://localhost/public/v1/pricing/streams/STR-4444-5555-6666/columns/SCOL-7755-7115-2464-010', + json={'output': False}, + ) + + if validate: + mocked_responses.add( + method='POST', + url='https://localhost/public/v1/pricing/streams/STR-4444-5555-6666/validate', + ) + + mocker.patch('connect.cli.plugins.commerce.utils.console') + mocked_cmd_console = mocker.patch( + 'connect.cli.plugins.commerce.commands.console', + ) + runner = CliRunner() + with tempfile.TemporaryDirectory() as tmpdir: + cmd = [ + 'commerce', + 'stream', + 'clone', + 'STR-7755-7115-2464', + ] + if validate: + cmd.append('-v') + result = runner.invoke( + ccli, + cmd, + ) + + assert result.exit_code == 0 + assert mocked_cmd_console.echo.call_count == 3 + mocked_cmd_console.confirm.assert_called_with( + 'Are you sure you want to Clone ' f'the stream STR-7755-7115-2464 ?', + abort=True, + ) + mocked_cmd_console.secho.assert_called_with( + f'Stream STR-7755-7115-2464 cloned properly to STR-4444-5555-6666.', + fg='green', + ) + + +def test_clone_computed_stream_empty_attachments_and_transformations( + mocker, + ccli, + mocked_responses, + config_mocker, +): + with open('./tests/fixtures/commerce/stream_retrieve_response.json') as content: + response = json.load(content)[0] + del response['samples'] + response['sources'] = {'something': 'else'} + mocked_responses.add( + method='GET', + url='https://localhost/public/v1/pricing/streams?eq(id,STR-7755-7115-2464)&select(context,samples,sources)&limit=1&offset=0', + json=[response], + ) + + mocked_responses.add( + method='POST', + url='https://localhost/public/v1/pricing/streams', + json={'id': 'STR-4444-5555-6666'}, + ) + + mocked_responses.add( + method='GET', + url='https://localhost/public/v1/media/folders/streams_attachments/STR-7755-7115-2464/files?limit=100&offset=0', + json=[], + ) + + mocked_responses.add( + method='GET', + url='https://localhost/public/v1/pricing/streams/STR-7755-7115-2464/transformations?limit=100&offset=0', + json=[], + ) + + with open('./tests/fixtures/commerce/columns_response.json') as content: + data = json.load(content) + mocked_responses.add( + method='GET', + url='https://localhost/public/v1/pricing/streams/STR-7755-7115-2464/columns?limit=100&offset=0', + json=data, + ) + mocked_responses.add( + method='GET', + url='https://localhost/public/v1/pricing/streams/STR-4444-5555-6666/columns?limit=100&offset=0', + json=data, + ) + + mocked_utils_console = mocker.patch('connect.cli.plugins.commerce.utils.console') + mocker.patch( + 'connect.cli.plugins.commerce.utils.guess_if_billing_or_pricing_stream', + return_value='pricing', + ) + + mocked_cmd_console = mocker.patch( + 'connect.cli.plugins.commerce.commands.console', + ) + + runner = CliRunner() + with tempfile.TemporaryDirectory() as tmpdir: + cmd = [ + 'commerce', + 'stream', + 'clone', + 'STR-7755-7115-2464', + ] + result = runner.invoke( + ccli, + cmd, + ) + + assert result.exit_code == 0 + assert mocked_cmd_console.echo.call_count == 3 + mocked_cmd_console.confirm.assert_called_with( + 'Are you sure you want to Clone ' f'the stream STR-7755-7115-2464 ?', + abort=True, + ) + mocked_cmd_console.secho.assert_called_with( + f'Stream STR-7755-7115-2464 cloned properly to STR-4444-5555-6666.', + fg='green', + ) + + +def test_clone_destination_account( + mocker, + ccli, + mocked_responses, + config_mocker, +): + with open('./tests/fixtures/commerce/stream_retrieve_response.json') as content: + response = json.load(content)[0] + mocked_responses.add( + method='GET', + url='https://localhost/public/v1/pricing/streams?eq(id,STR-7755-7115-2464)&select(context,samples,sources)&limit=1&offset=0', + json=[response], + ) + + mocked_responses.add( + method='GET', + url='https://localhost/public/v1/products?eq(id,PRD-054-258-626)&limit=0&offset=0', + json=[], + headers={ + 'Content-Range': 'items 0-1/1', + }, + ) + mocked_responses.add( + method='GET', + url='https://localhost/public/v1/marketplaces?eq(id,MP-05011)&limit=0&offset=0', + json=[], + headers={ + 'Content-Range': 'items 0-1/1', + }, + ) + mocked_responses.add( + method='GET', + url='https://localhost/public/v1/accounts?eq(id,PA-050-101)&limit=0&offset=0', + json=[], + headers={ + 'Content-Range': 'items 0-1/1', + }, + ) + mocked_responses.add( + method='GET', + url='https://localhost/public/v1/pricelists?eq(id,PL-123)&limit=0&offset=0', + json=[], + headers={ + 'Content-Range': 'items 0-1/1', + }, + ) + + mocked_responses.add( + method='POST', + url='https://localhost/public/v1/pricing/streams', + json={'id': 'STR-4444-5555-6666'}, + ) + + mocked_responses.add( + method='GET', + url='https://localhost/public/v1/media/folders/streams_attachments/STR-7755-7115-2464/files?limit=100&offset=0', + json=[], + ) + + mocked_responses.add( + method='GET', + url='https://localhost/public/v1/pricing/streams/STR-7755-7115-2464/transformations?limit=100&offset=0', + json=[], + ) + + mocked_utils_console = mocker.patch('connect.cli.plugins.commerce.utils.console') + mocker.patch( + 'connect.cli.plugins.commerce.utils.guess_if_billing_or_pricing_stream', + return_value='pricing', + ) + mocker.patch( + 'connect.cli.plugins.commerce.utils.clone_sample', + ) + mocker.patch( + 'connect.cli.plugins.commerce.utils.align_column_output', + return_value=(0, 0), + ) + mocked_cmd_console = mocker.patch( + 'connect.cli.plugins.commerce.commands.console', + ) + + runner = CliRunner() + with tempfile.TemporaryDirectory(): + cmd = [ + 'commerce', + 'stream', + 'clone', + 'STR-7755-7115-2464', + '-d', + 'VA-001', + ] + result = runner.invoke( + ccli, + cmd, + ) + + assert result.exit_code == 0 + assert mocked_cmd_console.echo.call_count == 3 + mocked_cmd_console.confirm.assert_called_with( + 'Are you sure you want to Clone ' f'the stream STR-7755-7115-2464 ?', + abort=True, + ) + mocked_cmd_console.secho.assert_called_with( + f'Stream STR-7755-7115-2464 cloned properly to STR-4444-5555-6666.', + fg='green', + ) + mocked_utils_console.progress.assert_called() + + +def test_clone_destination_account_objects_not_found( + mocker, + ccli, + mocked_responses, + config_mocker, +): + with open('./tests/fixtures/commerce/stream_retrieve_response.json') as content: + response = json.load(content)[0] + mocked_responses.add( + method='GET', + url='https://localhost/public/v1/pricing/streams?eq(id,STR-7755-7115-2464)&select(context,samples,sources)&limit=1&offset=0', + json=[response], + ) + + mocked_responses.add( + method='GET', + url='https://localhost/public/v1/products?eq(id,PRD-054-258-626)&limit=0&offset=0', + json=[], + headers={ + 'Content-Range': 'items 0-0/0', + }, + ) + mocked_responses.add( + method='GET', + url='https://localhost/public/v1/marketplaces?eq(id,MP-05011)&limit=0&offset=0', + json=[], + headers={ + 'Content-Range': 'items 0-0/0', + }, + ) + mocked_responses.add( + method='GET', + url='https://localhost/public/v1/accounts?eq(id,PA-050-101)&limit=0&offset=0', + json=[], + headers={ + 'Content-Range': 'items 0-0/0', + }, + ) + mocked_responses.add( + method='GET', + url='https://localhost/public/v1/pricelists?eq(id,PL-123)&limit=0&offset=0', + json=[], + headers={ + 'Content-Range': 'items 0-0/0', + }, + ) + + mocked_responses.add( + method='POST', + url='https://localhost/public/v1/pricing/streams', + json={'id': 'STR-4444-5555-6666'}, + ) + + mocked_responses.add( + method='GET', + url='https://localhost/public/v1/media/folders/streams_attachments/STR-7755-7115-2464/files?limit=100&offset=0', + json=[], + ) + + mocked_responses.add( + method='GET', + url='https://localhost/public/v1/pricing/streams/STR-7755-7115-2464/transformations?limit=100&offset=0', + json=[], + ) + + mocked_utils_console = mocker.patch('connect.cli.plugins.commerce.utils.console') + mocker.patch( + 'connect.cli.plugins.commerce.utils.guess_if_billing_or_pricing_stream', + return_value='pricing', + ) + mocker.patch( + 'connect.cli.plugins.commerce.utils.clone_sample', + ) + mocker.patch( + 'connect.cli.plugins.commerce.utils.align_column_output', + return_value=(0, 0), + ) + mocked_cmd_console = mocker.patch( + 'connect.cli.plugins.commerce.commands.console', + ) + + runner = CliRunner() + with tempfile.TemporaryDirectory(): + cmd = [ + 'commerce', + 'stream', + 'clone', + 'STR-7755-7115-2464', + '-d', + 'VA-001', + ] + result = runner.invoke( + ccli, + cmd, + ) + + assert result.exit_code == 0 + assert mocked_cmd_console.echo.call_count == 3 + mocked_cmd_console.confirm.assert_called_with( + 'Are you sure you want to Clone ' f'the stream STR-7755-7115-2464 ?', + abort=True, + ) + mocked_cmd_console.secho.assert_called_with( + f'Stream STR-7755-7115-2464 cloned properly to STR-4444-5555-6666.', + fg='green', + ) + mocked_utils_console.progress.assert_called() + assert mocked_utils_console.secho.call_count == 4 + call_args = mocked_utils_console.secho.call_args_list + assert call_args[0] == mocker.call( + 'The product PRD-054-258-626 does not exists.', + fg='yellow', + ) + assert call_args[1] == mocker.call( + 'The marketplace MP-05011 does not exists.', + fg='yellow', + ) + assert call_args[2] == mocker.call( + 'The account PA-050-101 does not exists.', + fg='yellow', + ) + assert call_args[3] == mocker.call( + 'The pricelist PL-123 does not exists.', + fg='yellow', + ) + + +def test_clone_destination_account_not_found( + ccli, + config_mocker, +): + runner = CliRunner() + with tempfile.TemporaryDirectory(): + cmd = [ + 'commerce', + 'stream', + 'clone', + 'STR-7755-7115-2464', + '-d', + 'VA-666', + ] + result = runner.invoke( + ccli, + cmd, + ) + + assert result.exit_code == 1 + assert result.output == ( + 'Current active account: VA-000 - Account 0\n\nError: Error obtaining the destination account id VA-666\n' + ) diff --git a/tests/plugins/commerce/test_utils.py b/tests/plugins/commerce/test_utils.py index 43b755a..37dacce 100644 --- a/tests/plugins/commerce/test_utils.py +++ b/tests/plugins/commerce/test_utils.py @@ -1,5 +1,6 @@ import os import tempfile +import json import pytest from click import ClickException @@ -8,6 +9,9 @@ from connect.cli.plugins.commerce.utils import ( fill_and_download_attachments, guess_if_billing_or_pricing_stream, + clone_stream, + create_stream_from_origin, + clone_transformations, ) @@ -88,3 +92,190 @@ def test_fill_attachments_download_fails(mocked_responses, mocker, client): mocked_progress, ) assert exec.value == 'Error obtaining file name' + + +def test_clone_stream_not_found( + mocker, + mocked_responses, + client, +): + mocked_responses.add( + method='GET', + url='https://localhost/public/v1/billing/streams?eq(id,STR-7755-7115-2464)&limit=0&offset=0', + json=[], + headers={ + 'Content-Range': 'items 0-0/0', + }, + ) + mocked_responses.add( + method='GET', + url='https://localhost/public/v1/pricing/streams?eq(id,STR-7755-7115-2464)&limit=0&offset=0', + json=[], + headers={ + 'Content-Range': 'items 0-0/0', + }, + ) + + with pytest.raises(ClickException) as e: + clone_stream( + mocker.MagicMock(client=client, id='VA-000'), + 'STR-7755-7115-2464', + None, + ) + + assert str(e.value) == 'Stream STR-7755-7115-2464 not found for the current account VA-000.' + + +def test_clone_stream_computed_and_diff_accounts( + mocker, + mocked_responses, + client, +): + mocked_responses.add( + method='GET', + url='https://localhost/public/v1/billing/streams?eq(id,STR-7755-7115-2464)&limit=0&offset=0', + json=[], + headers={ + 'Content-Range': 'items 0-1/1', + }, + ) + with open('./tests/fixtures/commerce/stream_retrieve_response.json') as content: + response = json.load(content)[0] + response['sources'] = {'some': 'thing'} + mocked_responses.add( + method='GET', + url='https://localhost/public/v1/billing/streams?eq(id,STR-7755-7115-2464)&select(context,samples,sources)&limit=1&offset=0', + json=[response], + ) + + with pytest.raises(ClickException) as e: + clone_stream( + mocker.MagicMock(client=client, id='VA-000'), + 'STR-7755-7115-2464', + mocker.MagicMock(client=client, id='VA-001'), + ) + + assert str(e.value) == 'You cannot clone a Computed stream between different accounts.' + + +def test_clone_inbound_stream( + mocker, + mocked_responses, + client, +): + mocked_responses.add( + method='GET', + url='https://localhost/public/v1/billing/streams?eq(id,STR-7755-7115-2464)&limit=0&offset=0', + json=[], + headers={ + 'Content-Range': 'items 0-1/1', + }, + ) + with open('./tests/fixtures/commerce/stream_retrieve_response.json') as content: + response = json.load(content)[0] + response['owner']['id'] = 'VA-999' + mocked_responses.add( + method='GET', + url='https://localhost/public/v1/billing/streams?eq(id,STR-7755-7115-2464)&select(context,samples,sources)&limit=1&offset=0', + json=[response], + ) + + with pytest.raises(ClickException) as e: + clone_stream( + mocker.MagicMock(client=client, id='VA-000'), + 'STR-7755-7115-2464', + mocker.MagicMock(client=client, id='VA-001'), + ) + + assert str(e.value) == 'Inbound streams cannot be cloned.' + + +def test_create_stream_from_origin_error( + mocked_responses, + client, +): + mocked_responses.add( + method='POST', + url='https://localhost/public/v1/billing/streams', + status=400, + json={'error_code': 'error_code', 'errors': ['error one']}, + ) + with pytest.raises(ClickException) as e: + create_stream_from_origin( + client, + { + 'id': 'STR-123', + 'context': {}, + 'name': 'name', + 'sources': [], + 'owner': {'id': 'VA-000'}, + }, + 'billing', + ) + assert str(e.value) == 'error one' + + +def test_clone_transformations_with_two_columns_same_name( + mocker, +): + mocked_col_mapping = mocker.patch( + 'connect.cli.plugins.commerce.utils.generate_column_mapping', + return_value=({'name': ['COL-ID-1', 'COL-ID-2']}, {'COL-ID-1': '', 'COL-ID-2': ''}), + ) + mocked_progress = mocker.MagicMock() + clone_transformations( + mocker.MagicMock(), + 'STR-333', + 'billing', + {}, + [ + { + 'function': {'id': 'fn-123', 'name': 'Lookup Data from a stream attached Excel'}, + 'settings': {}, + 'overview': 'overview', + 'position': 1000, + 'columns': {'input': [{'id': 'COL-ID-2', 'name': 'name'}], 'output': []}, + }, + ], + {'name': ['COL-ID-1', 'COL-ID-2']}, + mocked_progress, + ) + mocked_col_mapping.assert_called() + mocked_progress.add_task.assert_called_with('Processing transformations', total=1) + assert mocked_progress.update.call_count == 1 + + +def test_clone_transformations_fix_file_mapping( + mocker, + client, + mocked_responses, +): + mocked_responses.add( + method='POST', + url='https://localhost/public/v1/billing/streams/STR-333/transformations', + ) + mocked_col_mapping = mocker.patch( + 'connect.cli.plugins.commerce.utils.generate_column_mapping', + return_value=({'name': ['COL-ID-1', 'COL-ID-2']}, {'COL-ID-1': '', 'COL-ID-2': ''}), + ) + mocked_progress = mocker.MagicMock() + clone_transformations( + client, + 'STR-333', + 'billing', + {'filepath': 'newfilepath'}, + [ + { + 'function': {'id': 'fn-123', 'name': 'Lookup Data from a stream attached Excel'}, + 'settings': {'file': 'filepath'}, + 'overview': 'overview', + 'position': 1000, + 'columns': {'input': [{'id': 'COL-ID-2', 'name': 'name'}], 'output': []}, + }, + ], + {'name': ['COL-ID-1', 'COL-ID-2']}, + mocked_progress, + ) + mocked_col_mapping.assert_called() + mocked_progress.add_task.assert_called_with('Processing transformations', total=1) + assert mocked_progress.update.call_count == 1