← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] lp:~cjwatson/launchpad/snap-export-store-channels into lp:launchpad

 

Colin Watson has proposed merging lp:~cjwatson/launchpad/snap-export-store-channels into lp:launchpad.

Commit message:
Export Snap.store_channels and SnapSet.new(store_channels=...) on the webservice.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~cjwatson/launchpad/snap-export-store-channels/+merge/314116

build.snapcraft.io needs this so that it can set up snaps properly.

I started by pushing the adjusted value_type from SnapView.store_channels down to the interface, and then had to arrange for the channel list to be JSON-serialisable in order to work properly on the webservice.  I opted for beefing up the vocabulary a bit so that it implicitly includes any channels already in its context as well as the set currently exposed by CPI, which simplifies validation.  After that it's mostly just a matter of making sure all the tests tolerate these changes, which is a bit noisy but not very exciting.
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~cjwatson/launchpad/snap-export-store-channels into lp:launchpad.
=== modified file 'lib/lp/snappy/browser/snap.py'
--- lib/lp/snappy/browser/snap.py	2016-12-15 19:54:46 +0000
+++ lib/lp/snappy/browser/snap.py	2017-01-04 21:19:31 +0000
@@ -1,4 +1,4 @@
-# Copyright 2015-2016 Canonical Ltd.  This software is licensed under the
+# Copyright 2015-2017 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Snap views."""
@@ -190,19 +190,12 @@
 
     @cachedproperty
     def store_channels(self):
-        if self.context.store_channels is not None:
-            vocabulary = getUtility(
-                IVocabularyFactory, name='SnapStoreChannel')(self.context)
-            channel_titles = []
-            for channel in self.context.store_channels:
-                try:
-                    channel_titles.append(
-                        vocabulary.getTermByToken(channel).title)
-                except LookupError:
-                    channel_titles.append(channel)
-            return ', '.join(channel_titles)
-        else:
-            return None
+        vocabulary = getUtility(
+            IVocabularyFactory, name='SnapStoreChannel')(self.context)
+        channel_titles = []
+        for channel in self.context.store_channels:
+            channel_titles.append(vocabulary.getTermByToken(channel).title)
+        return ', '.join(channel_titles)
 
 
 def builds_for_snap(snap):
@@ -356,9 +349,7 @@
     # This is only required if store_upload is True.  Later validation takes
     # care of adjusting the required attribute.
     store_name = copy_field(ISnap['store_name'], required=True)
-    store_channels = copy_field(
-        ISnap['store_channels'],
-        value_type=Choice(vocabulary='SnapStoreChannel'), required=True)
+    store_channels = copy_field(ISnap['store_channels'], required=True)
 
 
 def log_oops(error, request):

=== modified file 'lib/lp/snappy/browser/tests/test_snap.py'
--- lib/lp/snappy/browser/tests/test_snap.py	2016-12-15 19:54:46 +0000
+++ lib/lp/snappy/browser/tests/test_snap.py	2017-01-04 21:19:31 +0000
@@ -1,4 +1,4 @@
-# Copyright 2015-2016 Canonical Ltd.  This software is licensed under the
+# Copyright 2015-2017 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Test snap package views."""
@@ -139,30 +139,36 @@
                 branch, "+new-snap")
 
 
-class TestSnapAddView(BrowserTestCase):
+class BaseTestSnapView(BrowserTestCase):
 
     layer = LaunchpadFunctionalLayer
 
     def setUp(self):
+        super(BaseTestSnapView, self).setUp()
+        self.useFixture(FeatureFixture(SNAP_TESTING_FLAGS))
+        self.useFixture(FakeLogger())
+        self.snap_store_client = FakeMethod()
+        self.snap_store_client.listChannels = FakeMethod(result=[
+            {"name": "stable", "display_name": "Stable"},
+            {"name": "edge", "display_name": "Edge"},
+            ])
+        self.snap_store_client.requestPackageUploadPermission = (
+            getUtility(ISnapStoreClient).requestPackageUploadPermission)
+        self.useFixture(
+            ZopeUtilityFixture(self.snap_store_client, ISnapStoreClient))
+        self.person = self.factory.makePerson(
+            name="test-person", displayname="Test Person")
+
+
+class TestSnapAddView(BaseTestSnapView):
+
+    def setUp(self):
         super(TestSnapAddView, self).setUp()
-        self.useFixture(FeatureFixture(SNAP_TESTING_FLAGS))
-        self.useFixture(FakeLogger())
-        self.person = self.factory.makePerson(
-            name="test-person", displayname="Test Person")
         self.distroseries = self.factory.makeUbuntuDistroSeries(
             version="13.10")
         with admin_logged_in():
             self.snappyseries = self.factory.makeSnappySeries(
                 preferred_distro_series=self.distroseries)
-        self.snap_store_client = FakeMethod()
-        self.snap_store_client.listChannels = FakeMethod(result=[
-            {"name": "stable", "display_name": "Stable"},
-            {"name": "edge", "display_name": "Edge"},
-            ])
-        self.snap_store_client.requestPackageUploadPermission = (
-            getUtility(ISnapStoreClient).requestPackageUploadPermission)
-        self.useFixture(
-            ZopeUtilityFixture(self.snap_store_client, ISnapStoreClient))
 
     def setUpDistroSeries(self):
         """Set up a distroseries with some available processors."""
@@ -512,16 +518,7 @@
         self.assertEqual(1, safe_load.call_count)
 
 
-class TestSnapAdminView(BrowserTestCase):
-
-    layer = DatabaseFunctionalLayer
-
-    def setUp(self):
-        super(TestSnapAdminView, self).setUp()
-        self.useFixture(FeatureFixture(SNAP_TESTING_FLAGS))
-        self.useFixture(FakeLogger())
-        self.person = self.factory.makePerson(
-            name="test-person", displayname="Test Person")
+class TestSnapAdminView(BaseTestSnapView):
 
     def test_unauthorized(self):
         # A non-admin user cannot administer a snap package.
@@ -590,30 +587,15 @@
         self.assertSqlAttributeEqualsDate(snap, "date_last_modified", UTC_NOW)
 
 
-class TestSnapEditView(BrowserTestCase):
-
-    layer = LaunchpadFunctionalLayer
+class TestSnapEditView(BaseTestSnapView):
 
     def setUp(self):
         super(TestSnapEditView, self).setUp()
-        self.useFixture(FeatureFixture(SNAP_TESTING_FLAGS))
-        self.useFixture(FakeLogger())
-        self.person = self.factory.makePerson(
-            name="test-person", displayname="Test Person")
         self.distroseries = self.factory.makeUbuntuDistroSeries(
             version="13.10")
         with admin_logged_in():
             self.snappyseries = self.factory.makeSnappySeries(
                 usable_distro_series=[self.distroseries])
-        self.snap_store_client = FakeMethod()
-        self.snap_store_client.listChannels = FakeMethod(result=[
-            {"name": "stable", "display_name": "Stable"},
-            {"name": "edge", "display_name": "Edge"},
-            ])
-        self.snap_store_client.requestPackageUploadPermission = (
-            getUtility(ISnapStoreClient).requestPackageUploadPermission)
-        self.useFixture(
-            ZopeUtilityFixture(self.snap_store_client, ISnapStoreClient))
 
     def test_initial_store_series(self):
         # The initial store_series is the newest that is usable for the
@@ -825,7 +807,6 @@
     def test_edit_processors_restricted(self):
         # A restricted processor is shown disabled in the UI and cannot be
         # enabled.
-        self.useFixture(FakeLogger())
         distroseries = self.setUpDistroSeries()
         proc_armhf = self.factory.makeProcessor(
             name="armhf", restricted=True, build_by_default=False)
@@ -976,15 +957,10 @@
         self.assertEqual(expected_args, parse_qs(parsed_location[3]))
 
 
-class TestSnapAuthorizeView(BrowserTestCase):
-
-    layer = LaunchpadFunctionalLayer
+class TestSnapAuthorizeView(BaseTestSnapView):
 
     def setUp(self):
         super(TestSnapAuthorizeView, self).setUp()
-        self.useFixture(FeatureFixture(SNAP_TESTING_FLAGS))
-        self.person = self.factory.makePerson(
-            name="test-person", displayname="Test Person")
         self.distroseries = self.factory.makeUbuntuDistroSeries()
         with admin_logged_in():
             self.snappyseries = self.factory.makeSnappySeries(
@@ -997,7 +973,6 @@
 
     def test_unauthorized(self):
         # A user without edit access cannot authorize snap package uploads.
-        self.useFixture(FakeLogger())
         other_person = self.factory.makePerson()
         self.assertRaises(
             Unauthorized, self.getUserBrowser,
@@ -1093,19 +1068,10 @@
                 self.snap.store_secrets)
 
 
-class TestSnapDeleteView(BrowserTestCase):
-
-    layer = LaunchpadFunctionalLayer
-
-    def setUp(self):
-        super(TestSnapDeleteView, self).setUp()
-        self.useFixture(FeatureFixture(SNAP_TESTING_FLAGS))
-        self.person = self.factory.makePerson(
-            name="test-person", displayname="Test Person")
+class TestSnapDeleteView(BaseTestSnapView):
 
     def test_unauthorized(self):
         # A user without edit access cannot delete a snap package.
-        self.useFixture(FakeLogger())
         snap = self.factory.makeSnap(registrant=self.person, owner=self.person)
         snap_url = canonical_url(snap)
         other_person = self.factory.makePerson()
@@ -1118,7 +1084,6 @@
 
     def test_delete_snap_without_builds(self):
         # A snap package without builds can be deleted.
-        self.useFixture(FakeLogger())
         snap = self.factory.makeSnap(registrant=self.person, owner=self.person)
         snap_url = canonical_url(snap)
         owner_url = canonical_url(self.person)
@@ -1130,7 +1095,6 @@
 
     def test_delete_snap_with_builds(self):
         # A snap package with builds can be deleted.
-        self.useFixture(FakeLogger())
         snap = self.factory.makeSnap(registrant=self.person, owner=self.person)
         build = self.factory.makeSnapBuild(snap=snap)
         self.factory.makeSnapFile(snapbuild=build)
@@ -1143,13 +1107,10 @@
         self.assertRaises(NotFound, browser.open, snap_url)
 
 
-class TestSnapView(BrowserTestCase):
-
-    layer = LaunchpadFunctionalLayer
+class TestSnapView(BaseTestSnapView):
 
     def setUp(self):
         super(TestSnapView, self).setUp()
-        self.useFixture(FeatureFixture(SNAP_TESTING_FLAGS))
         self.ubuntu = getUtility(ILaunchpadCelebrities).ubuntu
         self.distroseries = self.factory.makeDistroSeries(
             distribution=self.ubuntu, name="shiny", displayname="Shiny")
@@ -1157,8 +1118,6 @@
         self.distroarchseries = self.factory.makeDistroArchSeries(
             distroseries=self.distroseries, architecturetag="i386",
             processor=processor)
-        self.person = self.factory.makePerson(
-            name="test-person", displayname="Test Person")
         self.factory.makeBuilder(virtualized=True)
 
     def makeSnap(self, **kwargs):
@@ -1372,32 +1331,35 @@
             self.setStatus(build, BuildStatus.FULLYBUILT)
         self.assertEqual(list(reversed(builds[1:])), view.builds)
 
-    def test_store_channels_none(self):
+    def test_store_channels_empty(self):
         snap = self.factory.makeSnap()
         view = create_initialized_view(snap, "+index")
-        self.assertIsNone(view.store_channels)
+        self.assertEqual("", view.store_channels)
 
     def test_store_channels_uses_titles(self):
         snap_store_client = FakeMethod()
         snap_store_client.listChannels = FakeMethod(result=[
             {"name": "stable", "display_name": "Stable"},
             {"name": "edge", "display_name": "Edge"},
+            {"name": "old", "display_name": "Old channel"},
             ])
         self.useFixture(
             ZopeUtilityFixture(snap_store_client, ISnapStoreClient))
-        snap = self.factory.makeSnap(store_channels=["stable", "nonexistent"])
-        view = create_initialized_view(snap, "+index")
-        self.assertEqual("Stable, nonexistent", view.store_channels)
-
-
-class TestSnapRequestBuildsView(BrowserTestCase):
-
-    layer = LaunchpadFunctionalLayer
+        snap = self.factory.makeSnap(store_channels=["stable", "old"])
+        view = create_initialized_view(snap, "+index")
+        self.assertEqual("Stable, Old channel", view.store_channels)
+        snap_store_client.listChannels.result = [
+            {"name": "stable", "display_name": "Stable"},
+            {"name": "edge", "display_name": "Edge"},
+            ]
+        view = create_initialized_view(snap, "+index")
+        self.assertEqual("Stable, old", view.store_channels)
+
+
+class TestSnapRequestBuildsView(BaseTestSnapView):
 
     def setUp(self):
         super(TestSnapRequestBuildsView, self).setUp()
-        self.useFixture(FeatureFixture(SNAP_TESTING_FLAGS))
-        self.useFixture(FakeLogger())
         self.ubuntu = getUtility(ILaunchpadCelebrities).ubuntu
         self.distroseries = self.factory.makeDistroSeries(
             distribution=self.ubuntu, name="shiny", displayname="Shiny")
@@ -1408,7 +1370,6 @@
                 processor=getUtility(IProcessorSet).getByName(processor))
             das.addOrUpdateChroot(self.factory.makeLibraryFileAlias())
             self.architectures.append(das)
-        self.person = self.factory.makePerson()
         self.snap = self.factory.makeSnap(
             registrant=self.person, owner=self.person,
             distroseries=self.distroseries, name=u"snap-name")

=== modified file 'lib/lp/snappy/interfaces/snap.py'
--- lib/lp/snappy/interfaces/snap.py	2016-12-15 19:54:46 +0000
+++ lib/lp/snappy/interfaces/snap.py	2017-01-04 21:19:31 +0000
@@ -1,4 +1,4 @@
-# Copyright 2015-2016 Canonical Ltd.  This software is licensed under the
+# Copyright 2015-2017 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Snap package interfaces."""
@@ -523,12 +523,12 @@
             "Serialized secrets issued by the store and the login service to "
             "authorize uploads of this snap package."))
 
-    store_channels = List(
-        value_type=TextLine(), title=_("Store channels"),
-        required=False, readonly=False,
+    store_channels = exported(List(
+        value_type=Choice(vocabulary="SnapStoreChannel"),
+        title=_("Store channels"), required=False, readonly=False,
         description=_(
             "Channels to release this snap package to after uploading it to "
-            "the store."))
+            "the store.")))
 
 
 class ISnapAdminAttributes(Interface):
@@ -578,7 +578,8 @@
             "owner", "distro_series", "name", "description", "branch",
             "git_repository", "git_repository_url", "git_path", "git_ref",
             "auto_build", "auto_build_archive", "auto_build_pocket",
-            "private", "store_upload", "store_series", "store_name"])
+            "private", "store_upload", "store_series", "store_name",
+            "store_channels"])
     @operation_for_version("devel")
     def new(registrant, owner, distro_series, name, description=None,
             branch=None, git_repository=None, git_repository_url=None,
@@ -586,7 +587,7 @@
             auto_build_archive=None, auto_build_pocket=None,
             require_virtualized=True, processors=None, date_created=None,
             private=False, store_upload=False, store_series=None,
-            store_name=None, store_secrets=None):
+            store_name=None, store_secrets=None, store_channels=None):
         """Create an `ISnap`."""
 
     def exists(owner, name):

=== modified file 'lib/lp/snappy/model/snap.py'
--- lib/lp/snappy/model/snap.py	2016-12-15 19:54:46 +0000
+++ lib/lp/snappy/model/snap.py	2017-01-04 21:19:31 +0000
@@ -1,4 +1,4 @@
-# Copyright 2015-2016 Canonical Ltd.  This software is licensed under the
+# Copyright 2015-2017 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 __metaclass__ = type
@@ -202,7 +202,7 @@
 
     store_secrets = JSON('store_secrets', allow_none=True)
 
-    store_channels = JSON('store_channels', allow_none=True)
+    _store_channels = JSON('store_channels', allow_none=True)
 
     def __init__(self, registrant, owner, distro_series, name,
                  description=None, branch=None, git_ref=None, auto_build=False,
@@ -346,6 +346,14 @@
         self.distro_series = value.distro_series
         self.store_series = value.snappy_series
 
+    @property
+    def store_channels(self):
+        return [] if self._store_channels is None else self._store_channels
+
+    @store_channels.setter
+    def store_channels(self, value):
+        self._store_channels = value if value else None
+
     @staticmethod
     def extractSSOCaveat(macaroon):
         locations = [

=== modified file 'lib/lp/snappy/templates/snap-index.pt'
--- lib/lp/snappy/templates/snap-index.pt	2016-07-26 13:20:56 +0000
+++ lib/lp/snappy/templates/snap-index.pt	2017-01-04 21:19:31 +0000
@@ -111,7 +111,7 @@
           <a tal:replace="structure view/menu:overview/edit/fmt:icon"/>
         </dd>
       </dl>
-      <p id="store_channels" tal:condition="not: context/store_channels">
+      <p id="store_channels" tal:condition="not: view/store_channels">
         This snap package will not be released to any channels on the store.
       </p>
     </div>

=== modified file 'lib/lp/snappy/tests/test_snap.py'
--- lib/lp/snappy/tests/test_snap.py	2016-12-16 00:50:53 +0000
+++ lib/lp/snappy/tests/test_snap.py	2017-01-04 21:19:31 +0000
@@ -1,4 +1,4 @@
-# Copyright 2015-2016 Canonical Ltd.  This software is licensed under the
+# Copyright 2015-2017 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Test snap packages."""
@@ -72,6 +72,7 @@
     ISnapBuildSet,
     )
 from lp.snappy.interfaces.snapbuildjob import ISnapStoreUploadJobSource
+from lp.snappy.interfaces.snapstoreclient import ISnapStoreClient
 from lp.snappy.model.snap import SnapSet
 from lp.snappy.model.snapbuild import SnapFile
 from lp.snappy.model.snapbuildjob import SnapBuildJob
@@ -86,6 +87,8 @@
     StormStatementRecorder,
     TestCaseWithFactory,
     )
+from lp.testing.fakemethod import FakeMethod
+from lp.testing.fixture import ZopeUtilityFixture
 from lp.testing.layers import (
     DatabaseFunctionalLayer,
     LaunchpadFunctionalLayer,
@@ -1119,6 +1122,15 @@
     def setUp(self):
         super(TestSnapWebservice, self).setUp()
         self.useFixture(FeatureFixture(SNAP_TESTING_FLAGS))
+        self.snap_store_client = FakeMethod()
+        self.snap_store_client.listChannels = FakeMethod(result=[
+            {"name": "stable", "display_name": "Stable"},
+            {"name": "edge", "display_name": "Edge"},
+            ])
+        self.snap_store_client.requestPackageUploadPermission = (
+            getUtility(ISnapStoreClient).requestPackageUploadPermission)
+        self.useFixture(
+            ZopeUtilityFixture(self.snap_store_client, ISnapStoreClient))
         self.person = self.factory.makePerson(displayname="Test Person")
         self.webservice = webservice_for_person(
             self.person, permission=OAuthPermission.WRITE_PUBLIC)
@@ -1131,14 +1143,13 @@
     def makeSnap(self, owner=None, distroseries=None, branch=None,
                  git_ref=None, processors=None, webservice=None,
                  private=False, auto_build_archive=None,
-                 auto_build_pocket=None):
+                 auto_build_pocket=None, **kwargs):
         if owner is None:
             owner = self.person
         if distroseries is None:
             distroseries = self.factory.makeDistroSeries(registrant=owner)
         if branch is None and git_ref is None:
             branch = self.factory.makeAnyBranch()
-        kwargs = {}
         if webservice is None:
             webservice = self.webservice
         transaction.commit()
@@ -1222,6 +1233,21 @@
         with person_logged_in(self.person):
             self.assertTrue(snap["private"])
 
+    def test_new_store_options(self):
+        # Ensure store-related options in Snap.new work.
+        with admin_logged_in():
+            snappy_series = self.factory.makeSnappySeries()
+        store_name = self.factory.getUniqueUnicode()
+        snap = self.makeSnap(
+            store_upload=True, store_series=api_url(snappy_series),
+            store_name=store_name, store_channels=["edge"])
+        with person_logged_in(self.person):
+            self.assertTrue(snap["store_upload"])
+            self.assertEqual(
+                self.getURL(snappy_series), snap["store_series_link"])
+            self.assertEqual(store_name, snap["store_name"])
+            self.assertEqual(["edge"], snap["store_channels"])
+
     def test_duplicate(self):
         # An attempt to create a duplicate Snap fails.
         team = self.factory.makeTeam(owner=self.person)

=== modified file 'lib/lp/snappy/tests/test_snapbuildjob.py'
--- lib/lp/snappy/tests/test_snapbuildjob.py	2016-11-03 20:31:00 +0000
+++ lib/lp/snappy/tests/test_snapbuildjob.py	2017-01-04 21:19:31 +0000
@@ -1,4 +1,4 @@
-# Copyright 2016 Canonical Ltd.  This software is licensed under the
+# Copyright 2016-2017 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Tests for snap build jobs."""
@@ -49,6 +49,7 @@
     def __init__(self):
         self.upload = FakeMethod()
         self.checkStatus = FakeMethod()
+        self.listChannels = FakeMethod(result=[])
         self.release = FakeMethod()
 
 

=== modified file 'lib/lp/snappy/tests/test_snapstoreclient.py'
--- lib/lp/snappy/tests/test_snapstoreclient.py	2016-09-19 13:30:27 +0000
+++ lib/lp/snappy/tests/test_snapstoreclient.py	2017-01-04 21:19:31 +0000
@@ -1,4 +1,4 @@
-# Copyright 2016 Canonical Ltd.  This software is licensed under the
+# Copyright 2016-2017 Canonical Ltd.  This software is licensed under the
 # GNU Affero General Public License version 3 (see the file LICENSE).
 
 """Tests for communication with the snap store."""
@@ -182,6 +182,10 @@
             "launchpad", openid_provider_root="http://sso.example/";)
         self.client = getUtility(ISnapStoreClient)
         self.unscanned_upload_requests = []
+        self.channels = [
+            {"name": "stable", "display_name": "Stable"},
+            {"name": "edge", "display_name": "Edge"},
+            ]
 
     def _make_store_secrets(self):
         self.root_key = hashlib.sha256(
@@ -231,6 +235,14 @@
             "content": {"discharge_macaroon": new_macaroon.serialize()},
             }
 
+    @urlmatch(path=r".*/api/v1/channels$")
+    def _channels_handler(self, url, request):
+        self.channels_request = request
+        return {
+            "status_code": 200,
+            "content": {"_embedded": {"clickindex:channel": self.channels}},
+            }
+
     @urlmatch(path=r".*/snap-release/$")
     def _snap_release_handler(self, url, request):
         self.snap_release_request = request
@@ -498,35 +510,22 @@
                 self.client.checkStatus, status_url)
 
     def test_listChannels(self):
-        expected_channels = [
-            {"name": "stable", "display_name": "Stable"},
-            {"name": "edge", "display_name": "Edge"},
-            ]
-
-        @all_requests
-        def handler(url, request):
-            self.request = request
-            return {
-                "status_code": 200,
-                "content": {
-                    "_embedded": {"clickindex:channel": expected_channels}}}
-
         memcache_key = "search.example:channels".encode("UTF-8")
         try:
-            with HTTMock(handler):
-                self.assertEqual(expected_channels, self.client.listChannels())
-            self.assertThat(self.request, RequestMatches(
+            with HTTMock(self._channels_handler):
+                self.assertEqual(self.channels, self.client.listChannels())
+            self.assertThat(self.channels_request, RequestMatches(
                 url=Equals("http://search.example/api/v1/channels";),
                 method=Equals("GET"),
                 headers=ContainsDict(
                     {"Accept": Equals("application/hal+json")})))
             self.assertEqual(
-                expected_channels,
+                self.channels,
                 json.loads(getUtility(IMemcacheClient).get(memcache_key)))
-            self.request = None
-            with HTTMock(handler):
-                self.assertEqual(expected_channels, self.client.listChannels())
-            self.assertIsNone(self.request)
+            self.channels_request = None
+            with HTTMock(self._channels_handler):
+                self.assertEqual(self.channels, self.client.listChannels())
+            self.assertIsNone(self.channels_request)
         finally:
             getUtility(IMemcacheClient).delete(memcache_key)
 
@@ -562,11 +561,13 @@
         self.assertIsNone(getUtility(IMemcacheClient).get(memcache_key))
 
     def test_release(self):
-        snap = self.factory.makeSnap(
-            store_upload=True,
-            store_series=self.factory.makeSnappySeries(name="rolling"),
-            store_name="test-snap", store_secrets=self._make_store_secrets(),
-            store_channels=["stable", "edge"])
+        with HTTMock(self._channels_handler):
+            snap = self.factory.makeSnap(
+                store_upload=True,
+                store_series=self.factory.makeSnappySeries(name="rolling"),
+                store_name="test-snap",
+                store_secrets=self._make_store_secrets(),
+                store_channels=["stable", "edge"])
         snapbuild = self.factory.makeSnapBuild(snap=snap)
         with HTTMock(self._snap_release_handler):
             self.client.release(snapbuild, 1)
@@ -588,11 +589,13 @@
                 "content": {"success": False, "errors": "Failed to publish"},
                 }
 
-        snap = self.factory.makeSnap(
-            store_upload=True,
-            store_series=self.factory.makeSnappySeries(name="rolling"),
-            store_name="test-snap", store_secrets=self._make_store_secrets(),
-            store_channels=["stable", "edge"])
+        with HTTMock(self._channels_handler):
+            snap = self.factory.makeSnap(
+                store_upload=True,
+                store_series=self.factory.makeSnappySeries(name="rolling"),
+                store_name="test-snap",
+                store_secrets=self._make_store_secrets(),
+                store_channels=["stable", "edge"])
         snapbuild = self.factory.makeSnapBuild(snap=snap)
         with HTTMock(handler):
             self.assertRaisesWithContent(
@@ -604,11 +607,13 @@
         def handler(url, request):
             return {"status_code": 404, "reason": b"Not found"}
 
-        snap = self.factory.makeSnap(
-            store_upload=True,
-            store_series=self.factory.makeSnappySeries(name="rolling"),
-            store_name="test-snap", store_secrets=self._make_store_secrets(),
-            store_channels=["stable", "edge"])
+        with HTTMock(self._channels_handler):
+            snap = self.factory.makeSnap(
+                store_upload=True,
+                store_series=self.factory.makeSnappySeries(name="rolling"),
+                store_name="test-snap",
+                store_secrets=self._make_store_secrets(),
+                store_channels=["stable", "edge"])
         snapbuild = self.factory.makeSnapBuild(snap=snap)
         with HTTMock(handler):
             self.assertRaisesWithContent(

=== modified file 'lib/lp/snappy/vocabularies.py'
--- lib/lp/snappy/vocabularies.py	2016-06-30 17:15:26 +0000
+++ lib/lp/snappy/vocabularies.py	2017-01-04 21:19:31 +0000
@@ -1,4 +1,4 @@
-# Copyright 2015-2016 Canonical Ltd.  This software is licensed under the GNU
+# Copyright 2015-2017 Canonical Ltd.  This software is licensed under the GNU
 # Affero General Public License version 3 (see the file LICENSE).
 
 """Snappy vocabularies."""
@@ -10,18 +10,22 @@
     'SnappySeriesVocabulary',
     ]
 
+from lazr.restful.interfaces import IJSONPublishable
 from storm.locals import Desc
 from zope.component import getUtility
+from zope.interface import implementer
 from zope.schema.vocabulary import (
     SimpleTerm,
     SimpleVocabulary,
     )
+from zope.security.proxy import removeSecurityProxy
 
 from lp.registry.model.distribution import Distribution
 from lp.registry.model.distroseries import DistroSeries
 from lp.registry.model.series import ACTIVE_STATUSES
 from lp.services.database.interfaces import IStore
 from lp.services.webapp.vocabulary import StormVocabularyBase
+from lp.snappy.interfaces.snap import ISnap
 from lp.snappy.interfaces.snapstoreclient import ISnapStoreClient
 from lp.snappy.model.snappyseries import (
     SnappyDistroSeries,
@@ -116,6 +120,15 @@
         ]
 
 
+@implementer(IJSONPublishable)
+class SnapStoreChannel(SimpleTerm):
+    """A store channel."""
+
+    def toDataForJSON(self, media_type):
+        """See `IJSONPublishable`."""
+        return self.token
+
+
 class SnapStoreChannelVocabulary(SimpleVocabulary):
     """A vocabulary for searching store channels."""
 
@@ -125,4 +138,18 @@
             self.createTerm(
                 channel["name"], channel["name"], channel["display_name"])
             for channel in channels]
+        if ISnap.providedBy(context):
+            # Supplement the vocabulary with any obsolete channels still
+            # used by this context.
+            context_channels = removeSecurityProxy(context)._store_channels
+            if context_channels is not None:
+                known_names = set(channel["name"] for channel in channels)
+                for name in context_channels:
+                    if name not in known_names:
+                        terms.append(self.createTerm(name, name, name))
         super(SnapStoreChannelVocabulary, self).__init__(terms)
+
+    @classmethod
+    def createTerm(cls, *args):
+        """See `SimpleVocabulary`."""
+        return SnapStoreChannel(*args)

=== modified file 'lib/lp/snappy/vocabularies.zcml'
--- lib/lp/snappy/vocabularies.zcml	2016-06-30 17:15:26 +0000
+++ lib/lp/snappy/vocabularies.zcml	2017-01-04 21:19:31 +0000
@@ -1,4 +1,4 @@
-<!-- Copyright 2015-2016 Canonical Ltd.  This software is licensed under the
+<!-- Copyright 2015-2017 Canonical Ltd.  This software is licensed under the
      GNU Affero General Public License version 3 (see the file LICENSE).
 -->
 
@@ -48,6 +48,11 @@
         <allow interface="lp.services.webapp.vocabulary.IHugeVocabulary" />
     </class>
 
+    <class class="lp.snappy.vocabularies.SnapStoreChannel">
+        <allow interface="zope.schema.interfaces.ITitledTokenizedTerm
+                          lazr.restful.interfaces.IJSONPublishable" />
+    </class>
+
     <securedutility
         name="SnapStoreChannel"
         component="lp.snappy.vocabularies.SnapStoreChannelVocabulary"


Follow ups