New N2VC interface + updated libjuju

This commit introduces the Python3 N2VC module, which acts as a standard
interface to the VCA.

The goal of this is to provide a common way for modules to interface
with the VCA.

- Updated libjuju from 0.6.1 to 0.7.3

Signed-off-by: Adam Israel <adam.israel@canonical.com>
Change-Id: Ide70fb5ae5797eb6486de24653dc09a23f9c009e
diff --git a/modules/libjuju/tests/base.py b/modules/libjuju/tests/base.py
index 96ed9c7..bae4b80 100644
--- a/modules/libjuju/tests/base.py
+++ b/modules/libjuju/tests/base.py
@@ -1,11 +1,12 @@
-import mock
+import inspect
 import subprocess
 import uuid
 
-import pytest
-
+import mock
+from juju.client.jujudata import FileJujuData
 from juju.controller import Controller
-from juju.client.connection import JujuData
+
+import pytest
 
 
 def is_bootstrapped():
@@ -14,60 +15,67 @@
         result.returncode == 0 and
         len(result.stdout.decode().strip()) > 0)
 
+
 bootstrapped = pytest.mark.skipif(
     not is_bootstrapped(),
     reason='bootstrapped Juju environment required')
 
+test_run_nonce = uuid.uuid4().hex[-4:]
+
 
 class CleanController():
     def __init__(self):
-        self.controller = None
+        self._controller = None
 
     async def __aenter__(self):
-        self.controller = Controller()
-        await self.controller.connect_current()
-        return self.controller
+        self._controller = Controller()
+        await self._controller.connect()
+        return self._controller
 
     async def __aexit__(self, exc_type, exc, tb):
-        await self.controller.disconnect()
+        await self._controller.disconnect()
 
 
 class CleanModel():
-    def __init__(self):
-        self.user_name = None
-        self.controller = None
-        self.controller_name = None
-        self.model = None
-        self.model_name = None
-        self.model_uuid = None
+    def __init__(self, bakery_client=None):
+        self._controller = None
+        self._model = None
+        self._model_uuid = None
+        self._bakery_client = bakery_client
 
     async def __aenter__(self):
-        self.controller = Controller()
-        juju_data = JujuData()
-        self.controller_name = juju_data.current_controller()
-        self.user_name = juju_data.accounts()[self.controller_name]['user']
-        await self.controller.connect_controller(self.controller_name)
+        model_nonce = uuid.uuid4().hex[-4:]
+        frame = inspect.stack()[1]
+        test_name = frame.function.replace('_', '-')
+        jujudata = TestJujuData()
+        self._controller = Controller(
+            jujudata=jujudata,
+            bakery_client=self._bakery_client,
+        )
+        controller_name = jujudata.current_controller()
+        user_name = jujudata.accounts()[controller_name]['user']
+        await self._controller.connect(controller_name)
 
-        self.model_name = 'test-{}'.format(uuid.uuid4())
-        self.model = await self.controller.add_model(self.model_name)
+        model_name = 'test-{}-{}-{}'.format(
+            test_run_nonce,
+            test_name,
+            model_nonce,
+        )
+        self._model = await self._controller.add_model(model_name)
+
+        # Change the JujuData instance so that it will return the new
+        # model as the current model name, so that we'll connect
+        # to it by default.
+        jujudata.set_model(
+            controller_name,
+            user_name + "/" + model_name,
+            self._model.info.uuid,
+        )
 
         # save the model UUID in case test closes model
-        self.model_uuid = self.model.info.uuid
+        self._model_uuid = self._model.info.uuid
 
-        # Ensure that we connect to the new model by default.  This also
-        # prevents failures if test was started with no current model.
-        self._patch_cm = mock.patch.object(JujuData, 'current_model',
-                                           return_value=self.model_name)
-        self._patch_cm.start()
-
-        # Ensure that the models data includes this model, since it doesn't
-        # get added to the client store by Controller.add_model().
-        self._orig_models = JujuData().models
-        self._patch_models = mock.patch.object(JujuData, 'models',
-                                               side_effect=self._models)
-        self._patch_models.start()
-
-        return self.model
+        return self._model
 
     def _models(self):
         result = self._orig_models()
@@ -78,11 +86,36 @@
         return result
 
     async def __aexit__(self, exc_type, exc, tb):
-        self._patch_models.stop()
-        self._patch_cm.stop()
-        await self.model.disconnect()
-        await self.controller.destroy_model(self.model_uuid)
-        await self.controller.disconnect()
+        await self._model.disconnect()
+        await self._controller.destroy_model(self._model_uuid)
+        await self._controller.disconnect()
+
+
+class TestJujuData(FileJujuData):
+    def __init__(self):
+        self.__controller_name = None
+        self.__model_name = None
+        self.__model_uuid = None
+        super().__init__()
+
+    def set_model(self, controller_name, model_name, model_uuid):
+        self.__controller_name = controller_name
+        self.__model_name = model_name
+        self.__model_uuid = model_uuid
+
+    def current_model(self, *args, **kwargs):
+        return self.__model_name or super().current_model(*args, **kwargs)
+
+    def models(self):
+        all_models = super().models()
+        if self.__model_name is None:
+            return all_models
+        all_models.setdefault(self.__controller_name, {})
+        all_models[self.__controller_name].setdefault('models', {})
+        cmodels = all_models[self.__controller_name]['models']
+        cmodels[self.__model_name] = {'uuid': self.__model_uuid}
+        return all_models
+>>>>>>> New N2VC interface + updated libjuju
 
 
 class AsyncMock(mock.MagicMock):
diff --git a/modules/libjuju/tests/integration/test_client.py b/modules/libjuju/tests/integration/test_client.py
index e4c9c92..240c471 100644
--- a/modules/libjuju/tests/integration/test_client.py
+++ b/modules/libjuju/tests/integration/test_client.py
@@ -1,7 +1,7 @@
-import pytest
-
 from juju.client import client
 
+import pytest
+
 from .. import base
 
 
@@ -9,7 +9,7 @@
 @pytest.mark.asyncio
 async def test_user_info(event_loop):
     async with base.CleanModel() as model:
-        controller_conn = await model.connection.controller()
+        controller_conn = await model.connection().controller()
 
         um = client.UserManagerFacade.from_connection(controller_conn)
         result = await um.UserInfo(
diff --git a/modules/libjuju/tests/integration/test_connection.py b/modules/libjuju/tests/integration/test_connection.py
index 290203d..79ad9d0 100644
--- a/modules/libjuju/tests/integration/test_connection.py
+++ b/modules/libjuju/tests/integration/test_connection.py
@@ -1,28 +1,19 @@
 import asyncio
+
+from juju.client import client
+from juju.client.connection import Connection
+
 import pytest
 
-from juju.client.connection import Connection
-from juju.client import client
 from .. import base
 
 
 @base.bootstrapped
 @pytest.mark.asyncio
-async def test_connect_current(event_loop):
-    async with base.CleanModel():
-        conn = await Connection.connect_current()
-
-        assert isinstance(conn, Connection)
-        await conn.close()
-
-
-@base.bootstrapped
-@pytest.mark.asyncio
 async def test_monitor(event_loop):
 
-    async with base.CleanModel():
-        conn = await Connection.connect_current()
-
+    async with base.CleanModel() as model:
+        conn = model.connection()
         assert conn.monitor.status == 'connected'
         await conn.close()
 
@@ -33,15 +24,17 @@
 @pytest.mark.asyncio
 async def test_monitor_catches_error(event_loop):
 
-    async with base.CleanModel():
-        conn = await Connection.connect_current()
+    async with base.CleanModel() as model:
+        conn = model.connection()
 
         assert conn.monitor.status == 'connected'
-        await conn.ws.close()
-
-        assert conn.monitor.status == 'error'
-
-        await conn.close()
+        try:
+            async with conn.monitor.reconnecting:
+                await conn.ws.close()
+                await asyncio.sleep(1)
+                assert conn.monitor.status == 'error'
+        finally:
+            await conn.close()
 
 
 @base.bootstrapped
@@ -55,7 +48,7 @@
             channel='stable',
         )
 
-        c = client.ClientFacade.from_connection(model.connection)
+        c = client.ClientFacade.from_connection(model.connection())
 
         await c.FullStatus(None)
 
@@ -64,15 +57,8 @@
 @pytest.mark.asyncio
 async def test_reconnect(event_loop):
     async with base.CleanModel() as model:
-        conn = await Connection.connect(
-            model.connection.endpoint,
-            model.connection.uuid,
-            model.connection.username,
-            model.connection.password,
-            model.connection.cacert,
-            model.connection.macaroons,
-            model.connection.loop,
-            model.connection.max_frame_size)
+        kwargs = model.connection().connect_params()
+        conn = await Connection.connect(**kwargs)
         try:
             await asyncio.sleep(0.1)
             assert conn.is_open
diff --git a/modules/libjuju/tests/integration/test_controller.py b/modules/libjuju/tests/integration/test_controller.py
index d559313..9c6f7ac 100644
--- a/modules/libjuju/tests/integration/test_controller.py
+++ b/modules/libjuju/tests/integration/test_controller.py
@@ -2,10 +2,13 @@
 import pytest
 import uuid
 
-from .. import base
-from juju.controller import Controller
+from juju.client.connection import Connection
 from juju.errors import JujuAPIError
 
+import pytest
+
+from .. import base
+
 
 @base.bootstrapped
 @pytest.mark.asyncio
@@ -57,14 +60,18 @@
         username = 'test-password{}'.format(uuid.uuid4())
         user = await controller.add_user(username)
         await user.set_password('password')
+        # Check that we can connect with the new password.
+        new_connection = None
         try:
-            new_controller = Controller()
-            await new_controller.connect(
-                controller.connection.endpoint, username, 'password')
+            kwargs = controller.connection().connect_params()
+            kwargs['username'] = username
+            kwargs['password'] = 'password'
+            new_connection = await Connection.connect(**kwargs)
         except JujuAPIError:
             raise AssertionError('Unable to connect with new password')
         finally:
-            await new_controller.disconnect()
+            if new_connection:
+                await new_connection.close()
 
 
 @base.bootstrapped
@@ -77,10 +84,10 @@
         assert user.access == 'superuser'
         fresh = await controller.get_user(username)  # fetch fresh copy
         assert fresh.access == 'superuser'
-        await user.grant('login')
-        assert user.access == 'login'
+        await user.grant('login')  # already has 'superuser', so no-op
+        assert user.access == 'superuser'
         fresh = await controller.get_user(username)  # fetch fresh copy
-        assert fresh.access == 'login'
+        assert fresh.access == 'superuser'
         await user.revoke()
         assert user.access is ''
         fresh = await controller.get_user(username)  # fetch fresh copy
@@ -120,6 +127,11 @@
             await controller.destroy_model(model_name)
 
 
+async def _wait_for_model(controller, model_name):
+    while model_name not in await controller.list_models():
+        await asyncio.sleep(0.5, loop=controller.loop)
+
+
 async def _wait_for_model_gone(controller, model_name):
     while model_name in await controller.list_models():
         await asyncio.sleep(0.5, loop=controller.loop)
@@ -132,6 +144,9 @@
         model_name = 'test-{}'.format(uuid.uuid4())
         model = await controller.add_model(model_name)
         await model.disconnect()
+        await asyncio.wait_for(_wait_for_model(controller,
+                                               model_name),
+                               timeout=60)
         await controller.destroy_model(model_name)
         await asyncio.wait_for(_wait_for_model_gone(controller,
                                                     model_name),
@@ -146,6 +161,9 @@
         model = await controller.add_model(model_name)
         model_uuid = model.info.uuid
         await model.disconnect()
+        await asyncio.wait_for(_wait_for_model(controller,
+                                               model_name),
+                               timeout=60)
         await controller.destroy_model(model_uuid)
         await asyncio.wait_for(_wait_for_model_gone(controller,
                                                     model_name),
diff --git a/modules/libjuju/tests/integration/test_errors.py b/modules/libjuju/tests/integration/test_errors.py
index 06b3826..b10dd06 100644
--- a/modules/libjuju/tests/integration/test_errors.py
+++ b/modules/libjuju/tests/integration/test_errors.py
@@ -40,7 +40,7 @@
     from juju.client import client
 
     async with base.CleanModel() as model:
-        ann_facade = client.AnnotationsFacade.from_connection(model.connection)
+        ann_facade = client.AnnotationsFacade.from_connection(model.connection())
 
         ann = client.EntityAnnotations(
             entity='badtag',
@@ -58,11 +58,11 @@
     looking at a single result coming back.
 
     '''
-    from juju.errors import JujuError    
+    from juju.errors import JujuError
     from juju.client import client
 
     async with base.CleanModel() as model:
-        app_facade = client.ApplicationFacade.from_connection(model.connection)
+        app_facade = client.ApplicationFacade.from_connection(model.connection())
 
         with pytest.raises(JujuError):
             return await app_facade.GetCharmURL('foo')
diff --git a/modules/libjuju/tests/integration/test_machine.py b/modules/libjuju/tests/integration/test_machine.py
index cabf46d..8957ae1 100644
--- a/modules/libjuju/tests/integration/test_machine.py
+++ b/modules/libjuju/tests/integration/test_machine.py
@@ -1,8 +1,8 @@
 import asyncio
-import pytest
-
 from tempfile import NamedTemporaryFile
 
+import pytest
+
 from .. import base
 
 
@@ -42,6 +42,11 @@
 @base.bootstrapped
 @pytest.mark.asyncio
 async def test_scp(event_loop):
+    # ensure that asyncio.subprocess will work;
+    try:
+        asyncio.get_child_watcher().attach_loop(event_loop)
+    except RuntimeError:
+        pytest.skip('test_scp will always fail outside of MainThread')
     async with base.CleanModel() as model:
         await model.add_machine()
         await asyncio.wait_for(
diff --git a/modules/libjuju/tests/integration/test_model.py b/modules/libjuju/tests/integration/test_model.py
index 041f75a..ba2da92 100644
--- a/modules/libjuju/tests/integration/test_model.py
+++ b/modules/libjuju/tests/integration/test_model.py
@@ -1,11 +1,16 @@
 import asyncio
+import mock
 from concurrent.futures import ThreadPoolExecutor
 from pathlib import Path
+
+from juju.client.client import ConfigValue, ApplicationFacade
+from juju.model import Model, ModelObserver
+from juju.utils import block_until, run_with_interrupt
+
 import pytest
 
 from .. import base
-from juju.model import Model
-from juju.client.client import ConfigValue
+
 
 MB = 1
 GB = 1024
@@ -18,16 +23,30 @@
     from pathlib import Path
     tests_dir = Path(__file__).absolute().parent.parent
     bundle_path = tests_dir / 'bundle'
+    mini_bundle_file_path = bundle_path / 'mini-bundle.yaml'
 
     async with base.CleanModel() as model:
         await model.deploy(str(bundle_path))
+        await model.deploy(str(mini_bundle_file_path))
 
-        for app in ('wordpress', 'mysql'):
+        for app in ('wordpress', 'mysql', 'myapp'):
             assert app in model.applications
 
 
 @base.bootstrapped
 @pytest.mark.asyncio
+async def test_deploy_local_charm(event_loop):
+    from pathlib import Path
+    tests_dir = Path(__file__).absolute().parent.parent
+    charm_path = tests_dir / 'charm'
+
+    async with base.CleanModel() as model:
+        await model.deploy(str(charm_path))
+        assert 'charm' in model.applications
+
+
+@base.bootstrapped
+@pytest.mark.asyncio
 async def test_deploy_bundle(event_loop):
     async with base.CleanModel() as model:
         await model.deploy('bundle/wiki-simple')
@@ -43,7 +62,7 @@
         charm = 'cs:~johnsca/libjuju-test'
         stable = await model.deploy(charm, 'a1')
         edge = await model.deploy(charm, 'a2', channel='edge')
-        rev = await model.deploy(charm+'-2', 'a3')
+        rev = await model.deploy(charm + '-2', 'a3')
 
         assert [a.charm_url for a in (stable, edge, rev)] == [
             'cs:~johnsca/libjuju-test-1',
@@ -111,17 +130,44 @@
             # subordinates must be deployed without units
             num_units=0,
         )
-        my_relation = await model.add_relation(
-            'ubuntu',
-            'nrpe',
-        )
+
+        relation_added = asyncio.Event()
+        timeout = asyncio.Event()
+
+        class TestObserver(ModelObserver):
+            async def on_relation_add(self, delta, old, new, model):
+                if set(new.key.split()) == {'nrpe:general-info',
+                                            'ubuntu:juju-info'}:
+                    relation_added.set()
+                    event_loop.call_later(2, timeout.set)
+
+        model.add_observer(TestObserver())
+
+        real_app_facade = ApplicationFacade.from_connection(model.connection())
+        mock_app_facade = mock.MagicMock()
+
+        async def mock_AddRelation(*args):
+            # force response delay from AddRelation to test race condition
+            # (see https://github.com/juju/python-libjuju/issues/191)
+            result = await real_app_facade.AddRelation(*args)
+            await relation_added.wait()
+            return result
+
+        mock_app_facade.AddRelation = mock_AddRelation
+
+        with mock.patch.object(ApplicationFacade, 'from_connection',
+                               return_value=mock_app_facade):
+            my_relation = await run_with_interrupt(model.add_relation(
+                'ubuntu',
+                'nrpe',
+            ), timeout, event_loop)
 
         assert isinstance(my_relation, Relation)
 
 
-async def _deploy_in_loop(new_loop, model_name):
-    new_model = Model(new_loop)
-    await new_model.connect_model(model_name)
+async def _deploy_in_loop(new_loop, model_name, jujudata):
+    new_model = Model(new_loop, jujudata=jujudata)
+    await new_model.connect(model_name)
     try:
         await new_model.deploy('cs:xenial/ubuntu')
         assert 'ubuntu' in new_model.applications
@@ -138,7 +184,7 @@
         with ThreadPoolExecutor(1) as executor:
             f = executor.submit(
                 new_loop.run_until_complete,
-                _deploy_in_loop(new_loop, model_name))
+                _deploy_in_loop(new_loop, model_name, model._connector.jujudata))
             f.result()
         await model._wait_for_new('application', 'ubuntu')
         assert 'ubuntu' in model.applications
@@ -155,7 +201,7 @@
             lambda: (
                 len(ghost.units) > 0 and
                 ghost.units[0].workload_status in terminal_statuses)
-            )
+        )
         # ghost will go in to blocked (or error, for older
         # charm revs) if the resource is missing
         assert ghost.units[0].workload_status == 'active'
@@ -174,7 +220,7 @@
             lambda: (
                 len(ghost.units) > 0 and
                 ghost.units[0].workload_status in terminal_statuses)
-            )
+        )
         # ghost will go in to blocked (or error, for older
         # charm revs) if the resource is missing
         assert ghost.units[0].workload_status == 'active'
@@ -206,9 +252,8 @@
 @pytest.mark.asyncio
 async def test_watcher_reconnect(event_loop):
     async with base.CleanModel() as model:
-        await model.connection.ws.close()
-        await asyncio.sleep(0.1)
-        assert model.connection.is_open
+        await model.connection().ws.close()
+        await block_until(model.is_connected, timeout=3)
 
 
 @base.bootstrapped
diff --git a/modules/libjuju/tests/integration/test_unit.py b/modules/libjuju/tests/integration/test_unit.py
index 1604c31..8b2251c 100644
--- a/modules/libjuju/tests/integration/test_unit.py
+++ b/modules/libjuju/tests/integration/test_unit.py
@@ -1,8 +1,8 @@
 import asyncio
-import pytest
-
 from tempfile import NamedTemporaryFile
 
+import pytest
+
 from .. import base
 
 
@@ -52,6 +52,11 @@
 @base.bootstrapped
 @pytest.mark.asyncio
 async def test_scp(event_loop):
+    # ensure that asyncio.subprocess will work;
+    try:
+        asyncio.get_child_watcher().attach_loop(event_loop)
+    except RuntimeError:
+        pytest.skip('test_scp will always fail outside of MainThread')
     async with base.CleanModel() as model:
         app = await model.deploy('ubuntu')
 
diff --git a/modules/libjuju/tests/unit/test_client.py b/modules/libjuju/tests/unit/test_client.py
index e9fde8e..42134df 100644
--- a/modules/libjuju/tests/unit/test_client.py
+++ b/modules/libjuju/tests/unit/test_client.py
@@ -5,7 +5,6 @@
 
 import mock
 
-
 from juju.client import client
 
 
diff --git a/modules/libjuju/tests/unit/test_connection.py b/modules/libjuju/tests/unit/test_connection.py
index f69b8d6..0925d84 100644
--- a/modules/libjuju/tests/unit/test_connection.py
+++ b/modules/libjuju/tests/unit/test_connection.py
@@ -1,13 +1,14 @@
 import asyncio
 import json
-import mock
-import pytest
 from collections import deque
 
+import mock
+from juju.client.connection import Connection
 from websockets.exceptions import ConnectionClosed
 
+import pytest
+
 from .. import base
-from juju.client.connection import Connection
 
 
 class WebsocketMock:
@@ -31,7 +32,6 @@
 
 @pytest.mark.asyncio
 async def test_out_of_order(event_loop):
-    con = Connection(*[None]*4)
     ws = WebsocketMock([
         {'request-id': 1},
         {'request-id': 3},
@@ -42,13 +42,24 @@
         {'request-id': 2},
         {'request-id': 3},
     ]
-    con._get_sll = mock.MagicMock()
+    minimal_facades = [{'name': 'Pinger', 'versions': [1]}]
+    con = None
     try:
-        with mock.patch('websockets.connect', base.AsyncMock(return_value=ws)):
-            await con.open()
+        with \
+                mock.patch('websockets.connect', base.AsyncMock(return_value=ws)), \
+                mock.patch(
+                    'juju.client.connection.Connection.login',
+                    base.AsyncMock(return_value={'response': {
+                        'facades': minimal_facades,
+                    }}),
+                ), \
+                mock.patch('juju.client.connection.Connection._get_ssl'), \
+                mock.patch('juju.client.connection.Connection._pinger', base.AsyncMock()):
+            con = await Connection.connect('0.1.2.3:999')
         actual_responses = []
         for i in range(3):
             actual_responses.append(await con.rpc({'version': 1}))
         assert actual_responses == expected_responses
     finally:
-        await con.close()
+        if con:
+            await con.close()
diff --git a/modules/libjuju/tests/unit/test_constraints.py b/modules/libjuju/tests/unit/test_constraints.py
index cb9d773..00b9156 100644
--- a/modules/libjuju/tests/unit/test_constraints.py
+++ b/modules/libjuju/tests/unit/test_constraints.py
@@ -6,6 +6,7 @@
 
 from juju import constraints
 
+
 class TestConstraints(unittest.TestCase):
 
     def test_mem_regex(self):
diff --git a/modules/libjuju/tests/unit/test_loop.py b/modules/libjuju/tests/unit/test_loop.py
index f12368e..9043df6 100644
--- a/modules/libjuju/tests/unit/test_loop.py
+++ b/modules/libjuju/tests/unit/test_loop.py
@@ -1,5 +1,6 @@
 import asyncio
 import unittest
+
 import juju.loop
 
 
@@ -15,6 +16,7 @@
 
     def test_run(self):
         assert asyncio.get_event_loop() == self.loop
+
         async def _test():
             return 'success'
         self.assertEqual(juju.loop.run(_test()), 'success')
diff --git a/modules/libjuju/tests/unit/test_model.py b/modules/libjuju/tests/unit/test_model.py
index 222d881..2e33236 100644
--- a/modules/libjuju/tests/unit/test_model.py
+++ b/modules/libjuju/tests/unit/test_model.py
@@ -1,8 +1,11 @@
 import unittest
 
 import mock
+
 import asynctest
 
+from juju.client.jujudata import FileJujuData
+
 
 def _make_delta(entity, type_, data=None):
     from juju.client.client import Delta
@@ -68,8 +71,8 @@
         from juju.model import Model
         from juju.application import Application
 
-        loop = mock.MagicMock()
-        model = Model(loop=loop)
+        model = Model()
+        model._connector = mock.MagicMock()
         delta = _make_delta('application', 'add', dict(name='foo'))
 
         # test add
@@ -118,7 +121,7 @@
 
 class TestContextManager(asynctest.TestCase):
     @asynctest.patch('juju.model.Model.disconnect')
-    @asynctest.patch('juju.model.Model.connect_current')
+    @asynctest.patch('juju.model.Model.connect')
     async def test_normal_use(self, mock_connect, mock_disconnect):
         from juju.model import Model
 
@@ -129,7 +132,7 @@
         self.assertTrue(mock_disconnect.called)
 
     @asynctest.patch('juju.model.Model.disconnect')
-    @asynctest.patch('juju.model.Model.connect_current')
+    @asynctest.patch('juju.model.Model.connect')
     async def test_exception(self, mock_connect, mock_disconnect):
         from juju.model import Model
 
@@ -143,13 +146,57 @@
         self.assertTrue(mock_connect.called)
         self.assertTrue(mock_disconnect.called)
 
-    @asynctest.patch('juju.client.connection.JujuData.current_controller')
-    async def test_no_current_connection(self, mock_current_controller):
+    async def test_no_current_connection(self):
         from juju.model import Model
         from juju.errors import JujuConnectionError
 
-        mock_current_controller.return_value = ""
+        class NoControllerJujuData(FileJujuData):
+            def current_controller(self):
+                return ""
 
         with self.assertRaises(JujuConnectionError):
-            async with Model():
+            async with Model(jujudata=NoControllerJujuData()):
                 pass
+
+
+class TestModelConnect(asynctest.TestCase):
+    @asynctest.patch('juju.client.connector.Connector.connect_model')
+    @asynctest.patch('juju.model.Model._after_connect')
+    async def test_model_connect_no_args(self, mock_after_connect, mock_connect_model):
+        from juju.model import Model
+        m = Model()
+        await m.connect()
+        mock_connect_model.assert_called_once_with(None)
+
+    @asynctest.patch('juju.client.connector.Connector.connect_model')
+    @asynctest.patch('juju.model.Model._after_connect')
+    async def test_model_connect_with_model_name(self, mock_after_connect, mock_connect_model):
+        from juju.model import Model
+        m = Model()
+        await m.connect(model_name='foo')
+        mock_connect_model.assert_called_once_with('foo')
+
+    @asynctest.patch('juju.client.connector.Connector.connect_model')
+    @asynctest.patch('juju.model.Model._after_connect')
+    async def test_model_connect_with_endpoint_but_no_uuid(
+        self,
+        mock_after_connect,
+        mock_connect_model,
+    ):
+        from juju.model import Model
+        m = Model()
+        with self.assertRaises(ValueError):
+            await m.connect(endpoint='0.1.2.3:4566')
+        self.assertEqual(mock_connect_model.call_count, 0)
+
+    @asynctest.patch('juju.client.connector.Connector.connect')
+    @asynctest.patch('juju.model.Model._after_connect')
+    async def test_model_connect_with_endpoint_and_uuid(
+        self,
+        mock_after_connect,
+        mock_connect,
+    ):
+        from juju.model import Model
+        m = Model()
+        await m.connect(endpoint='0.1.2.3:4566', uuid='some-uuid')
+        mock_connect.assert_called_once_with(endpoint='0.1.2.3:4566', uuid='some-uuid')
diff --git a/modules/libjuju/tests/unit/test_overrides.py b/modules/libjuju/tests/unit/test_overrides.py
index 6485408..a5835ff 100644
--- a/modules/libjuju/tests/unit/test_overrides.py
+++ b/modules/libjuju/tests/unit/test_overrides.py
@@ -1,6 +1,6 @@
-import pytest
+from juju.client.overrides import Binary, Number  # noqa
 
-from juju.client.overrides import Number, Binary  # noqa
+import pytest
 
 
 # test cases ported from:
diff --git a/modules/libjuju/tests/unit/test_placement.py b/modules/libjuju/tests/unit/test_placement.py
index a78a28d..5a933ec 100644
--- a/modules/libjuju/tests/unit/test_placement.py
+++ b/modules/libjuju/tests/unit/test_placement.py
@@ -5,7 +5,7 @@
 import unittest
 
 from juju import placement
-from juju.client import client
+
 
 class TestPlacement(unittest.TestCase):