launchpad-reviewers team mailing list archive
-
launchpad-reviewers team
-
Mailing list archive
-
Message #19067
[Merge] lp:~cjwatson/launchpad/snap-webservice into lp:launchpad
Colin Watson has proposed merging lp:~cjwatson/launchpad/snap-webservice into lp:launchpad with lp:~cjwatson/launchpad/snap-builds as a prerequisite.
Commit message:
Export Snap and SnapBuild on the webservice.
Requested reviews:
Launchpad code reviewers (launchpad-reviewers)
Related bugs:
Bug #1476405 in Launchpad itself: "Add support for building snaps"
https://bugs.launchpad.net/launchpad/+bug/1476405
For more details, see:
https://code.launchpad.net/~cjwatson/launchpad/snap-webservice/+merge/265700
Export Snap and SnapBuild on the webservice.
--
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~cjwatson/launchpad/snap-webservice into lp:launchpad.
=== modified file 'lib/lp/_schema_circular_imports.py'
--- lib/lp/_schema_circular_imports.py 2015-06-05 11:05:03 +0000
+++ lib/lp/_schema_circular_imports.py 2015-07-23 16:45:08 +0000
@@ -182,6 +182,11 @@
ILanguage,
ILanguageSet,
)
+from lp.snappy.interfaces.snap import ISnapView
+from lp.snappy.interfaces.snapbuild import (
+ ISnapBuild,
+ ISnapFile,
+ )
from lp.soyuz.enums import (
PackagePublishingStatus,
PackageUploadCustomFormat,
@@ -606,6 +611,15 @@
patch_reference_property(IPackageUpload, 'archive', IArchive)
patch_reference_property(IPackageUpload, 'copy_source_archive', IArchive)
+# ISnapFile
+patch_reference_property(ISnapFile, 'snapbuild', ISnapBuild)
+
+# ISnapView
+patch_entry_return_type(ISnapView, 'requestBuild', ISnapBuild)
+patch_collection_property(ISnapView, 'builds', ISnapBuild)
+patch_collection_property(ISnapView, 'completed_builds', ISnapBuild)
+patch_collection_property(ISnapView, 'pending_builds', ISnapBuild)
+
# IStructuralSubscription
patch_collection_property(
IStructuralSubscription, 'bug_filters', IBugSubscriptionFilter)
=== modified file 'lib/lp/app/browser/launchpad.py'
--- lib/lp/app/browser/launchpad.py 2015-07-08 16:05:11 +0000
+++ lib/lp/app/browser/launchpad.py 2015-07-23 16:45:08 +0000
@@ -157,6 +157,7 @@
from lp.services.webapp.url import urlappend
from lp.services.worlddata.interfaces.country import ICountrySet
from lp.services.worlddata.interfaces.language import ILanguageSet
+from lp.snappy.interfaces.snap import ISnapSet
from lp.soyuz.interfaces.archive import IArchiveSet
from lp.soyuz.interfaces.binarypackagename import IBinaryPackageNameSet
from lp.soyuz.interfaces.livefs import ILiveFSSet
@@ -797,6 +798,7 @@
'+processors': IProcessorSet,
'projects': IProductSet,
'projectgroups': IProjectGroupSet,
+ '+snaps': ISnapSet,
'sourcepackagenames': ISourcePackageNameSet,
'specs': ISpecificationSet,
'sprints': ISprintSet,
=== modified file 'lib/lp/app/browser/tests/test_webservice.py'
--- lib/lp/app/browser/tests/test_webservice.py 2014-05-06 12:54:34 +0000
+++ lib/lp/app/browser/tests/test_webservice.py 2015-07-23 16:45:08 +0000
@@ -153,6 +153,12 @@
object_type = 'questions'
+class TestMissingSnaps(BaseMissingObjectWebService, TestCaseWithFactory):
+ """Test NotFound for webservice snaps requests."""
+
+ object_type = '+snaps'
+
+
class TestMissingTemporaryBlobs(
BaseMissingObjectWebService, TestCaseWithFactory):
"""Test NotFound for webservice temporary_blobs requests."""
=== modified file 'lib/lp/buildmaster/browser/builder.py'
--- lib/lp/buildmaster/browser/builder.py 2015-03-24 09:59:20 +0000
+++ lib/lp/buildmaster/browser/builder.py 2015-07-23 16:45:08 +0000
@@ -55,6 +55,7 @@
)
from lp.services.webapp.batching import StormRangeFactory
from lp.services.webapp.breadcrumb import Breadcrumb
+from lp.snappy.interfaces.snapbuild import ISnapBuildSet
from lp.soyuz.browser.build import (
BuildRecordsView,
get_build_by_id_str,
@@ -88,6 +89,13 @@
return None
return self.redirectSubTree(canonical_url(build))
+ @stepthrough('+snapbuild')
+ def traverse_snapbuild(self, name):
+ build = get_build_by_id_str(ISnapBuildSet, name)
+ if build is None:
+ return None
+ return self.redirectSubTree(canonical_url(build))
+
class BuilderSetBreadcrumb(Breadcrumb):
"""Builds a breadcrumb for an `IBuilderSet`."""
=== modified file 'lib/lp/snappy/browser/configure.zcml'
--- lib/lp/snappy/browser/configure.zcml 2015-07-23 16:45:08 +0000
+++ lib/lp/snappy/browser/configure.zcml 2015-07-23 16:45:08 +0000
@@ -17,6 +17,10 @@
module="lp.snappy.browser.snap"
classes="SnapNavigation" />
<browser:url
+ for="lp.snappy.interfaces.snap.ISnapSet"
+ path_expression="string:+snaps"
+ parent_utility="lp.services.webapp.interfaces.ILaunchpadRoot" />
+ <browser:url
for="lp.snappy.interfaces.snapbuild.ISnapBuild"
path_expression="string:+build/${id}"
attribute_to_parent="snap" />
=== modified file 'lib/lp/snappy/configure.zcml'
--- lib/lp/snappy/configure.zcml 2015-07-23 16:45:08 +0000
+++ lib/lp/snappy/configure.zcml 2015-07-23 16:45:08 +0000
@@ -70,4 +70,6 @@
<allow interface="lp.snappy.interfaces.snapbuild.ISnapFile" />
</class>
+ <webservice:register module="lp.snappy.interfaces.webservice" />
+
</configure>
=== modified file 'lib/lp/snappy/interfaces/snap.py'
--- lib/lp/snappy/interfaces/snap.py 2015-07-23 16:45:08 +0000
+++ lib/lp/snappy/interfaces/snap.py 2015-07-23 16:45:08 +0000
@@ -19,16 +19,33 @@
'NoSuchSnap',
]
+import httplib
+
+from lazr.lifecycle.snapshot import doNotSnapshot
+from lazr.restful.declarations import (
+ call_with,
+ collection_default_content,
+ error_status,
+ export_as_webservice_collection,
+ export_as_webservice_entry,
+ export_destructor_operation,
+ export_factory_operation,
+ export_read_operation,
+ exported,
+ operation_for_version,
+ operation_parameters,
+ operation_returns_entry,
+ REQUEST_USER,
+ )
from lazr.restful.fields import (
+ CollectionField,
Reference,
ReferenceChoice,
)
-from zope.interface import (
- Attribute,
- Interface,
- )
+from zope.interface import Interface
from zope.schema import (
Bool,
+ Choice,
Datetime,
Int,
Text,
@@ -45,16 +62,21 @@
from lp.code.interfaces.branch import IBranch
from lp.code.interfaces.gitrepository import IGitRepository
from lp.registry.interfaces.distroseries import IDistroSeries
+from lp.registry.interfaces.person import IPerson
+from lp.registry.interfaces.pocket import PackagePublishingPocket
from lp.registry.interfaces.role import IHasOwner
from lp.services.fields import (
PersonChoice,
PublicPersonChoice,
)
+from lp.soyuz.interfaces.archive import IArchive
+from lp.soyuz.interfaces.distroarchseries import IDistroArchSeries
SNAP_FEATURE_FLAG = u"snap.allow_new"
+@error_status(httplib.BAD_REQUEST)
class SnapBuildAlreadyPending(Exception):
"""A build was requested when an identical build was already pending."""
@@ -63,6 +85,7 @@
"An identical build of this snap package is already pending.")
+@error_status(httplib.FORBIDDEN)
class SnapBuildArchiveOwnerMismatch(Forbidden):
"""Builds against private archives require that owners match.
@@ -80,6 +103,7 @@
"if the snap package owner and the archive owner are equal.")
+@error_status(httplib.UNAUTHORIZED)
class SnapFeatureDisabled(Unauthorized):
"""Only certain users can create new snap-related objects."""
@@ -89,6 +113,7 @@
"builds.")
+@error_status(httplib.BAD_REQUEST)
class DuplicateSnapName(Exception):
"""Raised for snap packages with duplicate name/owner."""
@@ -97,6 +122,7 @@
"There is already a snap package with the same name and owner.")
+@error_status(httplib.UNAUTHORIZED)
class SnapNotOwner(Unauthorized):
"""The registrant/requester is not the owner or a member of its team."""
@@ -106,6 +132,7 @@
_message_prefix = "No such snap package with this owner"
+@error_status(httplib.BAD_REQUEST)
class CannotDeleteSnap(Exception):
"""This snap package cannot be deleted."""
@@ -115,14 +142,22 @@
id = Int(title=_("ID"), required=True, readonly=True)
- date_created = Datetime(
- title=_("Date created"), required=True, readonly=True)
+ date_created = exported(Datetime(
+ title=_("Date created"), required=True, readonly=True))
- registrant = PublicPersonChoice(
+ registrant = exported(PublicPersonChoice(
title=_("Registrant"), required=True, readonly=True,
vocabulary="ValidPersonOrTeam",
- description=_("The person who registered this snap package."))
+ description=_("The person who registered this snap package.")))
+ @call_with(requester=REQUEST_USER)
+ @operation_parameters(
+ archive=Reference(schema=IArchive),
+ distro_arch_series=Reference(schema=IDistroArchSeries),
+ pocket=Choice(vocabulary=PackagePublishingPocket))
+ # Really ISnapBuild, patched in _schema_circular_imports.py.
+ @export_factory_operation(Interface, [])
+ @operation_for_version("devel")
def requestBuild(requester, archive, distro_arch_series, pocket):
"""Request that the snap package be built.
@@ -133,16 +168,36 @@
:return: `ISnapBuild`.
"""
- builds = Attribute("All builds of this snap package.")
-
- completed_builds = Attribute("Completed builds of this snap package.")
-
- pending_builds = Attribute("Pending builds of this snap package.")
+ builds = exported(doNotSnapshot(CollectionField(
+ title=_("All builds of this snap package."),
+ description=_(
+ "All builds of this snap package, sorted in descending order "
+ "of finishing (or starting if not completed successfully)."),
+ # Really ISnapBuild, patched in _schema_circular_imports.py.
+ value_type=Reference(schema=Interface), readonly=True)))
+
+ completed_builds = exported(doNotSnapshot(CollectionField(
+ title=_("Completed builds of this snap package."),
+ description=_(
+ "Completed builds of this snap package, sorted in descending "
+ "order of finishing."),
+ # Really ISnapBuild, patched in _schema_circular_imports.py.
+ value_type=Reference(schema=Interface), readonly=True)))
+
+ pending_builds = exported(doNotSnapshot(CollectionField(
+ title=_("Pending builds of this snap package."),
+ description=_(
+ "Pending builds of this snap package, sorted in descending "
+ "order of creation."),
+ # Really ISnapBuild, patched in _schema_circular_imports.py.
+ value_type=Reference(schema=Interface), readonly=True)))
class ISnapEdit(Interface):
"""`ISnap` methods that require launchpad.Edit permission."""
+ @export_destructor_operation()
+ @operation_for_version("devel")
def destroySelf():
"""Delete this snap package, provided that it has no builds."""
@@ -152,48 +207,48 @@
These attributes need launchpad.View to see, and launchpad.Edit to change.
"""
- date_last_modified = Datetime(
- title=_("Date last modified"), required=True, readonly=True)
+ date_last_modified = exported(Datetime(
+ title=_("Date last modified"), required=True, readonly=True))
- owner = PersonChoice(
+ owner = exported(PersonChoice(
title=_("Owner"), required=True, readonly=False,
vocabulary="AllUserTeamsParticipationPlusSelf",
- description=_("The owner of this snap package."))
+ description=_("The owner of this snap package.")))
- distro_series = Reference(
+ distro_series = exported(Reference(
IDistroSeries, title=_("Distro Series"), required=True, readonly=False,
description=_(
- "The series for which the snap package should be built."))
+ "The series for which the snap package should be built.")))
- name = TextLine(
+ name = exported(TextLine(
title=_("Name"), required=True, readonly=False,
constraint=name_validator,
- description=_("The name of the snap package."))
+ description=_("The name of the snap package.")))
- description = Text(
+ description = exported(Text(
title=_("Description"), required=False, readonly=False,
- description=_("A description of the snap package."))
+ description=_("A description of the snap package.")))
- branch = ReferenceChoice(
+ branch = exported(ReferenceChoice(
title=_("Bazaar branch"), schema=IBranch, vocabulary="Branch",
required=False, readonly=False,
description=_(
"A Bazaar branch containing a snapcraft.yaml recipe at the top "
- "level."))
+ "level.")))
- git_repository = ReferenceChoice(
+ git_repository = exported(ReferenceChoice(
title=_("Git repository"),
schema=IGitRepository, vocabulary="GitRepository",
required=False, readonly=False,
description=_(
"A Git repository with a branch containing a snapcraft.yaml "
- "recipe at the top level."))
+ "recipe at the top level.")))
- git_path = TextLine(
+ git_path = exported(TextLine(
title=_("Git branch path"), required=False, readonly=False,
description=_(
"The path of the Git branch containing a snapcraft.yaml recipe at "
- "the top level."))
+ "the top level.")))
class ISnapAdminAttributes(Interface):
@@ -201,19 +256,32 @@
These attributes need launchpad.View to see, and launchpad.Admin to change.
"""
- require_virtualized = Bool(
+ require_virtualized = exported(Bool(
title=_("Require virtualized builders"), required=True, readonly=False,
- description=_("Only build this snap package on virtual builders."))
+ description=_("Only build this snap package on virtual builders.")))
class ISnap(
ISnapView, ISnapEdit, ISnapEditableAttributes, ISnapAdminAttributes):
"""A buildable snap package."""
+ # XXX cjwatson 2015-07-17 bug=760849: "beta" is a lie to get WADL
+ # generation working. Individual attributes must set their version to
+ # "devel".
+ export_as_webservice_entry(as_of="beta")
+
class ISnapSet(Interface):
"""A utility to create and access snap packages."""
+ export_as_webservice_collection(ISnap)
+
+ @call_with(registrant=REQUEST_USER)
+ @export_factory_operation(
+ ISnap, [
+ "owner", "distro_series", "name", "description", "branch",
+ "git_repository", "git_path"])
+ @operation_for_version("devel")
def new(registrant, owner, distro_series, name, description=None,
branch=None, git_repository=None, git_path=None,
require_virtualized=True, date_created=None):
@@ -222,12 +290,19 @@
def exists(owner, name):
"""Check to see if a matching snap exists."""
+ @operation_parameters(
+ owner=Reference(IPerson, title=_("Owner"), required=True),
+ name=TextLine(title=_("Snap name"), required=True))
+ @operation_returns_entry(ISnap)
+ @export_read_operation()
+ @operation_for_version("devel")
def getByName(owner, name):
"""Return the appropriate `ISnap` for the given objects."""
def getByPerson(owner):
"""Return all snap packages with the given `owner`."""
+ @collection_default_content()
def empty_list():
"""Return an empty collection of snap packages.
=== modified file 'lib/lp/snappy/interfaces/snapbuild.py'
--- lib/lp/snappy/interfaces/snapbuild.py 2015-07-23 16:45:08 +0000
+++ lib/lp/snappy/interfaces/snapbuild.py 2015-07-23 16:45:08 +0000
@@ -11,11 +11,16 @@
'ISnapFile',
]
+from lazr.restful.declarations import (
+ export_as_webservice_entry,
+ export_read_operation,
+ export_write_operation,
+ exported,
+ operation_for_version,
+ operation_parameters,
+ )
from lazr.restful.fields import Reference
-from zope.interface import (
- Attribute,
- Interface,
- )
+from zope.interface import Interface
from zope.schema import (
Bool,
Choice,
@@ -37,7 +42,11 @@
class ISnapFile(Interface):
"""A file produced by a snap package build."""
- snapbuild = Attribute("The snap package build producing this file.")
+ snapbuild = Reference(
+ # Really ISnapBuild, patched in _schema_circular_imports.py.
+ Interface,
+ title=_("The snap package build producing this file."),
+ required=True, readonly=True)
libraryfile = Reference(
ILibraryFileAlias, title=_("The library file alias for this file."),
@@ -47,46 +56,46 @@
class ISnapBuildView(IPackageBuild):
"""`ISnapBuild` attributes that require launchpad.View permission."""
- requester = Reference(
+ requester = exported(Reference(
IPerson,
title=_("The person who requested this build."),
- required=True, readonly=True)
+ required=True, readonly=True))
- snap = Reference(
+ snap = exported(Reference(
ISnap,
title=_("The snap package to build."),
- required=True, readonly=True)
+ required=True, readonly=True))
- archive = Reference(
+ archive = exported(Reference(
IArchive,
title=_("The archive from which to build the snap package."),
- required=True, readonly=True)
+ required=True, readonly=True))
- distro_arch_series = Reference(
+ distro_arch_series = exported(Reference(
IDistroArchSeries,
title=_("The series and architecture for which to build."),
- required=True, readonly=True)
+ required=True, readonly=True))
- pocket = Choice(
+ pocket = exported(Choice(
title=_("The pocket for which to build."),
- vocabulary=PackagePublishingPocket, required=True, readonly=True)
+ vocabulary=PackagePublishingPocket, required=True, readonly=True))
virtualized = Bool(
title=_("If True, this build is virtualized."), readonly=True)
- score = Int(
+ score = exported(Int(
title=_("Score of the related build farm job (if any)."),
- required=False, readonly=True)
+ required=False, readonly=True))
- can_be_rescored = Bool(
+ can_be_rescored = exported(Bool(
title=_("Can be rescored"),
required=True, readonly=True,
- description=_("Whether this build record can be rescored manually."))
+ description=_("Whether this build record can be rescored manually.")))
- can_be_cancelled = Bool(
+ can_be_cancelled = exported(Bool(
title=_("Can be cancelled"),
required=True, readonly=True,
- description=_("Whether this build record can be cancelled."))
+ description=_("Whether this build record can be cancelled.")))
def getFiles():
"""Retrieve the build's `ISnapFile` records.
@@ -111,6 +120,8 @@
:return: The corresponding `ILibraryFileAlias`.
"""
+ @export_read_operation()
+ @operation_for_version("devel")
def getFileUrls():
"""URLs for all the files produced by this build.
@@ -127,6 +138,8 @@
:return: An `ISnapFile`.
"""
+ @export_write_operation()
+ @operation_for_version("devel")
def cancel():
"""Cancel the build if it is either pending or in progress.
@@ -145,6 +158,9 @@
class ISnapBuildAdmin(Interface):
"""`ISnapBuild` attributes that require launchpad.Admin."""
+ @operation_parameters(score=Int(title=_("Score"), required=True))
+ @export_write_operation()
+ @operation_for_version("devel")
def rescore(score):
"""Change the build's score."""
@@ -152,6 +168,11 @@
class ISnapBuild(ISnapBuildView, ISnapBuildEdit, ISnapBuildAdmin):
"""Build information for snap package builds."""
+ # XXX cjwatson 2014-05-06 bug=760849: "beta" is a lie to get WADL
+ # generation working. Individual attributes must set their version to
+ # "devel".
+ export_as_webservice_entry(as_of="beta")
+
class ISnapBuildSet(ISpecificBuildFarmJobSource):
"""Utility for `ISnapBuild`."""
=== added file 'lib/lp/snappy/interfaces/webservice.py'
--- lib/lp/snappy/interfaces/webservice.py 1970-01-01 00:00:00 +0000
+++ lib/lp/snappy/interfaces/webservice.py 2015-07-23 16:45:08 +0000
@@ -0,0 +1,28 @@
+# Copyright 2015 Canonical Ltd. This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""All the interfaces that are exposed through the webservice.
+
+There is a declaration in ZCML somewhere that looks like:
+ <webservice:register module="lp.snappy.interfaces.webservice" />
+
+which tells `lazr.restful` that it should look for webservice exports here.
+"""
+
+__all__ = [
+ 'ISnap',
+ 'ISnapBuild',
+ 'ISnapSet',
+ ]
+
+# XXX: JonathanLange 2010-11-09 bug=673083: Legacy work-around for circular
+# import bugs. Break this up into a per-package thing.
+from lp import _schema_circular_imports
+from lp.snappy.interfaces.snap import (
+ ISnap,
+ ISnapSet,
+ )
+from lp.snappy.interfaces.snapbuild import ISnapBuild
+
+
+_schema_circular_imports
=== modified file 'lib/lp/snappy/tests/test_snap.py'
--- lib/lp/snappy/tests/test_snap.py 2015-07-23 16:45:08 +0000
+++ lib/lp/snappy/tests/test_snap.py 2015-07-23 16:45:08 +0000
@@ -5,11 +5,15 @@
__metaclass__ = type
-from datetime import datetime
+from datetime import (
+ datetime,
+ timedelta,
+ )
from lazr.lifecycle.event import ObjectModifiedEvent
import pytz
from storm.locals import Store
+from testtools.matchers import Equals
import transaction
from zope.component import getUtility
from zope.event import notify
@@ -21,26 +25,39 @@
)
from lp.buildmaster.interfaces.buildqueue import IBuildQueue
from lp.buildmaster.model.buildqueue import BuildQueue
+from lp.registry.interfaces.distribution import IDistributionSet
from lp.registry.interfaces.pocket import PackagePublishingPocket
from lp.services.database.constants import UTC_NOW
from lp.services.features.testing import FeatureFixture
+from lp.services.webapp.interfaces import OAuthPermission
from lp.snappy.interfaces.snap import (
CannotDeleteSnap,
ISnap,
ISnapSet,
+ ISnapView,
SNAP_FEATURE_FLAG,
SnapBuildAlreadyPending,
SnapFeatureDisabled,
)
from lp.snappy.interfaces.snapbuild import ISnapBuild
from lp.testing import (
+ ANONYMOUS,
+ api_url,
+ login,
+ logout,
person_logged_in,
+ StormStatementRecorder,
TestCaseWithFactory,
)
from lp.testing.layers import (
DatabaseFunctionalLayer,
LaunchpadZopelessLayer,
)
+from lp.testing.matchers import (
+ DoesNotSnapshot,
+ HasQueryCount,
+ )
+from lp.testing.pages import webservice_for_person
class TestSnapFeatureFlag(TestCaseWithFactory):
@@ -69,6 +86,12 @@
with person_logged_in(snap.owner):
self.assertProvides(snap, ISnap)
+ def test_avoids_problematic_snapshots(self):
+ self.assertThat(
+ self.factory.makeSnap(),
+ DoesNotSnapshot(
+ ["builds", "completed_builds", "pending_builds"], ISnapView))
+
def test_initial_date_last_modified(self):
# The initial value of date_last_modified is date_created.
snap = self.factory.makeSnap(
@@ -349,3 +372,358 @@
snaps[:2], getUtility(ISnapSet).getByPerson(owners[0]))
self.assertContentEqual(
snaps[2:], getUtility(ISnapSet).getByPerson(owners[1]))
+
+
+class TestSnapWebservice(TestCaseWithFactory):
+
+ layer = DatabaseFunctionalLayer
+
+ def setUp(self):
+ super(TestSnapWebservice, self).setUp()
+ self.useFixture(FeatureFixture({SNAP_FEATURE_FLAG: u"on"}))
+ self.person = self.factory.makePerson(displayname="Test Person")
+ self.webservice = webservice_for_person(
+ self.person, permission=OAuthPermission.WRITE_PUBLIC)
+ self.webservice.default_api_version = "devel"
+ login(ANONYMOUS)
+
+ def getURL(self, obj):
+ return self.webservice.getAbsoluteUrl(api_url(obj))
+
+ def makeSnap(self, owner=None, distroseries=None, branch=None,
+ git_ref=None, webservice=None):
+ 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()
+ distroseries_url = api_url(distroseries)
+ owner_url = api_url(owner)
+ if branch is not None:
+ kwargs["branch"] = api_url(branch)
+ if git_ref is not None:
+ kwargs["git_repository"] = api_url(git_ref.repository)
+ kwargs["git_path"] = git_ref.path
+ logout()
+ response = webservice.named_post(
+ "/+snaps", "new", owner=owner_url, distro_series=distroseries_url,
+ name="mir", **kwargs)
+ self.assertEqual(201, response.status)
+ return webservice.get(response.getHeader("Location")).jsonBody()
+
+ def getCollectionLinks(self, entry, member):
+ """Return a list of self_link attributes of entries in a collection."""
+ collection = self.webservice.get(
+ entry["%s_collection_link" % member]).jsonBody()
+ return [entry["self_link"] for entry in collection["entries"]]
+
+ def test_new_bzr(self):
+ # Ensure Snap creation based on a Bazaar branch works.
+ team = self.factory.makeTeam(owner=self.person)
+ distroseries = self.factory.makeDistroSeries(registrant=team)
+ branch = self.factory.makeAnyBranch()
+ snap = self.makeSnap(
+ owner=team, distroseries=distroseries, branch=branch)
+ with person_logged_in(self.person):
+ self.assertEqual(self.getURL(self.person), snap["registrant_link"])
+ self.assertEqual(self.getURL(team), snap["owner_link"])
+ self.assertEqual(
+ self.getURL(distroseries), snap["distro_series_link"])
+ self.assertEqual("mir", snap["name"])
+ self.assertEqual(self.getURL(branch), snap["branch_link"])
+ self.assertIsNone(snap["git_repository_link"])
+ self.assertIsNone(snap["git_path"])
+ self.assertTrue(snap["require_virtualized"])
+
+ def test_new_git(self):
+ # Ensure Snap creation based on a Git branch works.
+ team = self.factory.makeTeam(owner=self.person)
+ distroseries = self.factory.makeDistroSeries(registrant=team)
+ [ref] = self.factory.makeGitRefs()
+ snap = self.makeSnap(
+ owner=team, distroseries=distroseries, git_ref=ref)
+ with person_logged_in(self.person):
+ self.assertEqual(self.getURL(self.person), snap["registrant_link"])
+ self.assertEqual(self.getURL(team), snap["owner_link"])
+ self.assertEqual(
+ self.getURL(distroseries), snap["distro_series_link"])
+ self.assertEqual("mir", snap["name"])
+ self.assertIsNone(snap["branch_link"])
+ self.assertEqual(
+ self.getURL(ref.repository), snap["git_repository_link"])
+ self.assertEqual(ref.path, snap["git_path"])
+ self.assertTrue(snap["require_virtualized"])
+
+ def test_duplicate(self):
+ # An attempt to create a duplicate Snap fails.
+ team = self.factory.makeTeam(owner=self.person)
+ branch = self.factory.makeAnyBranch()
+ branch_url = api_url(branch)
+ self.makeSnap(owner=team)
+ with person_logged_in(self.person):
+ owner_url = api_url(team)
+ distroseries_url = api_url(self.factory.makeDistroSeries())
+ response = self.webservice.named_post(
+ "/+snaps", "new", owner=owner_url, distro_series=distroseries_url,
+ name="mir", branch=branch_url)
+ self.assertEqual(400, response.status)
+ self.assertEqual(
+ "There is already a snap package with the same name and owner.",
+ response.body)
+
+ def test_not_owner(self):
+ # If the registrant is not the owner or a member of the owner team,
+ # Snap creation fails.
+ other_person = self.factory.makePerson(displayname="Other Person")
+ other_team = self.factory.makeTeam(
+ owner=other_person, displayname="Other Team")
+ distroseries = self.factory.makeDistroSeries(registrant=self.person)
+ branch = self.factory.makeAnyBranch()
+ transaction.commit()
+ other_person_url = api_url(other_person)
+ other_team_url = api_url(other_team)
+ distroseries_url = api_url(distroseries)
+ branch_url = api_url(branch)
+ logout()
+ response = self.webservice.named_post(
+ "/+snaps", "new", owner=other_person_url,
+ distro_series=distroseries_url, name="dummy", branch=branch_url)
+ self.assertEqual(401, response.status)
+ self.assertEqual(
+ "Test Person cannot create snap packages owned by Other Person.",
+ response.body)
+ response = self.webservice.named_post(
+ "/+snaps", "new", owner=other_team_url,
+ distro_series=distroseries_url, name="dummy", branch=branch_url)
+ self.assertEqual(401, response.status)
+ self.assertEqual(
+ "Test Person is not a member of Other Team.", response.body)
+
+ def test_getByName(self):
+ # lp.snaps.getByName returns a matching Snap.
+ snap = self.makeSnap()
+ with person_logged_in(self.person):
+ owner_url = api_url(self.person)
+ response = self.webservice.named_get(
+ "/+snaps", "getByName", owner=owner_url, name="mir")
+ self.assertEqual(200, response.status)
+ self.assertEqual(snap, response.jsonBody())
+
+ def test_getByName_missing(self):
+ # lp.snaps.getByName returns 404 for a non-existent Snap.
+ logout()
+ with person_logged_in(self.person):
+ owner_url = api_url(self.person)
+ response = self.webservice.named_get(
+ "/+snaps", "getByName", owner=owner_url, name="nonexistent")
+ self.assertEqual(404, response.status)
+ self.assertEqual(
+ "No such snap package with this owner: 'nonexistent'.",
+ response.body)
+
+ def test_requestBuild(self):
+ # Build requests can be performed and end up in snap.builds and
+ # snap.pending_builds.
+ distroseries = self.factory.makeDistroSeries(registrant=self.person)
+ distroarchseries = self.factory.makeDistroArchSeries(
+ distroseries=distroseries, owner=self.person)
+ distroarchseries_url = api_url(distroarchseries)
+ archive_url = api_url(distroseries.main_archive)
+ snap = self.makeSnap(distroseries=distroseries)
+ response = self.webservice.named_post(
+ snap["self_link"], "requestBuild", archive=archive_url,
+ distro_arch_series=distroarchseries_url, pocket="Release")
+ self.assertEqual(201, response.status)
+ build = self.webservice.get(response.getHeader("Location")).jsonBody()
+ self.assertEqual(
+ [build["self_link"]], self.getCollectionLinks(snap, "builds"))
+ self.assertEqual([], self.getCollectionLinks(snap, "completed_builds"))
+ self.assertEqual(
+ [build["self_link"]],
+ self.getCollectionLinks(snap, "pending_builds"))
+
+ def test_requestBuild_rejects_repeats(self):
+ # Build requests are rejected if already pending.
+ distroseries = self.factory.makeDistroSeries(registrant=self.person)
+ distroarchseries = self.factory.makeDistroArchSeries(
+ distroseries=distroseries, owner=self.person)
+ distroarchseries_url = api_url(distroarchseries)
+ archive_url = api_url(distroseries.main_archive)
+ snap = self.makeSnap(distroseries=distroseries)
+ response = self.webservice.named_post(
+ snap["self_link"], "requestBuild", archive=archive_url,
+ distro_arch_series=distroarchseries_url, pocket="Release")
+ self.assertEqual(201, response.status)
+ response = self.webservice.named_post(
+ snap["self_link"], "requestBuild", archive=archive_url,
+ distro_arch_series=distroarchseries_url, pocket="Release")
+ self.assertEqual(400, response.status)
+ self.assertEqual(
+ "An identical build of this snap package is already pending.",
+ response.body)
+
+ def test_requestBuild_not_owner(self):
+ # If the requester is not the owner or a member of the owner team,
+ # build requests are rejected.
+ other_team = self.factory.makeTeam(displayname="Other Team")
+ distroseries = self.factory.makeDistroSeries(registrant=self.person)
+ distroarchseries = self.factory.makeDistroArchSeries(
+ distroseries=distroseries, owner=self.person)
+ distroarchseries_url = api_url(distroarchseries)
+ archive_url = api_url(distroseries.main_archive)
+ other_webservice = webservice_for_person(
+ other_team.teamowner, permission=OAuthPermission.WRITE_PUBLIC)
+ other_webservice.default_api_version = "devel"
+ login(ANONYMOUS)
+ snap = self.makeSnap(
+ owner=other_team, distroseries=distroseries,
+ webservice=other_webservice)
+ response = self.webservice.named_post(
+ snap["self_link"], "requestBuild", archive=archive_url,
+ distro_arch_series=distroarchseries_url, pocket="Release")
+ self.assertEqual(401, response.status)
+ self.assertEqual(
+ "Test Person cannot create snap package builds owned by Other "
+ "Team.", response.body)
+
+ def test_requestBuild_archive_disabled(self):
+ # Build requests against a disabled archive are rejected.
+ distroseries = self.factory.makeDistroSeries(
+ distribution=getUtility(IDistributionSet)['ubuntu'],
+ registrant=self.person)
+ distroarchseries = self.factory.makeDistroArchSeries(
+ distroseries=distroseries, owner=self.person)
+ distroarchseries_url = api_url(distroarchseries)
+ archive = self.factory.makeArchive(
+ distribution=distroseries.distribution, owner=self.person,
+ enabled=False, displayname="Disabled Archive")
+ archive_url = api_url(archive)
+ snap = self.makeSnap(distroseries=distroseries)
+ response = self.webservice.named_post(
+ snap["self_link"], "requestBuild", archive=archive_url,
+ distro_arch_series=distroarchseries_url, pocket="Release")
+ self.assertEqual(403, response.status)
+ self.assertEqual("Disabled Archive is disabled.", response.body)
+
+ def test_requestBuild_archive_private_owners_match(self):
+ # Build requests against a private archive are allowed if the Snap
+ # and Archive owners match exactly.
+ distroseries = self.factory.makeDistroSeries(
+ distribution=getUtility(IDistributionSet)['ubuntu'],
+ registrant=self.person)
+ distroarchseries = self.factory.makeDistroArchSeries(
+ distroseries=distroseries, owner=self.person)
+ distroarchseries_url = api_url(distroarchseries)
+ archive = self.factory.makeArchive(
+ distribution=distroseries.distribution, owner=self.person,
+ private=True)
+ archive_url = api_url(archive)
+ snap = self.makeSnap(distroseries=distroseries)
+ response = self.webservice.named_post(
+ snap["self_link"], "requestBuild", archive=archive_url,
+ distro_arch_series=distroarchseries_url, pocket="Release")
+ self.assertEqual(201, response.status)
+
+ def test_requestBuild_archive_private_owners_mismatch(self):
+ # Build requests against a private archive are rejected if the Snap
+ # and Archive owners do not match exactly.
+ distroseries = self.factory.makeDistroSeries(
+ distribution=getUtility(IDistributionSet)['ubuntu'],
+ registrant=self.person)
+ distroarchseries = self.factory.makeDistroArchSeries(
+ distroseries=distroseries, owner=self.person)
+ distroarchseries_url = api_url(distroarchseries)
+ archive = self.factory.makeArchive(
+ distribution=distroseries.distribution, private=True)
+ archive_url = api_url(archive)
+ snap = self.makeSnap(distroseries=distroseries)
+ response = self.webservice.named_post(
+ snap["self_link"], "requestBuild", archive=archive_url,
+ distro_arch_series=distroarchseries_url, pocket="Release")
+ self.assertEqual(403, response.status)
+ self.assertEqual(
+ "Snap package builds against private archives are only allowed "
+ "if the snap package owner and the archive owner are equal.",
+ response.body)
+
+ def test_getBuilds(self):
+ # The builds, completed_builds, and pending_builds properties are as
+ # expected.
+ distroseries = self.factory.makeDistroSeries(
+ distribution=getUtility(IDistributionSet)['ubuntu'],
+ registrant=self.person)
+ distroarchseries = self.factory.makeDistroArchSeries(
+ distroseries=distroseries, owner=self.person)
+ distroarchseries_url = api_url(distroarchseries)
+ archives = [
+ self.factory.makeArchive(
+ distribution=distroseries.distribution, owner=self.person)
+ for x in range(4)]
+ archive_urls = [api_url(archive) for archive in archives]
+ snap = self.makeSnap(distroseries=distroseries)
+ builds = []
+ for archive_url in archive_urls:
+ response = self.webservice.named_post(
+ snap["self_link"], "requestBuild", archive=archive_url,
+ distro_arch_series=distroarchseries_url, pocket="Proposed")
+ self.assertEqual(201, response.status)
+ build = self.webservice.get(
+ response.getHeader("Location")).jsonBody()
+ builds.insert(0, build["self_link"])
+ self.assertEqual(builds, self.getCollectionLinks(snap, "builds"))
+ self.assertEqual([], self.getCollectionLinks(snap, "completed_builds"))
+ self.assertEqual(
+ builds, self.getCollectionLinks(snap, "pending_builds"))
+ snap = self.webservice.get(snap["self_link"]).jsonBody()
+
+ with person_logged_in(self.person):
+ db_snap = getUtility(ISnapSet).getByName(self.person, snap["name"])
+ db_builds = list(db_snap.builds)
+ db_builds[0].updateStatus(
+ BuildStatus.BUILDING, date_started=db_snap.date_created)
+ db_builds[0].updateStatus(
+ BuildStatus.FULLYBUILT,
+ date_finished=db_snap.date_created + timedelta(minutes=10))
+ snap = self.webservice.get(snap["self_link"]).jsonBody()
+ # Builds that have not yet been started are listed last. This does
+ # mean that pending builds that have never been started are sorted
+ # to the end, but means that builds that were cancelled before
+ # starting don't pollute the start of the collection forever.
+ self.assertEqual(builds, self.getCollectionLinks(snap, "builds"))
+ self.assertEqual(
+ builds[:1], self.getCollectionLinks(snap, "completed_builds"))
+ self.assertEqual(
+ builds[1:], self.getCollectionLinks(snap, "pending_builds"))
+
+ with person_logged_in(self.person):
+ db_builds[1].updateStatus(
+ BuildStatus.BUILDING, date_started=db_snap.date_created)
+ db_builds[1].updateStatus(
+ BuildStatus.FULLYBUILT,
+ date_finished=db_snap.date_created + timedelta(minutes=20))
+ snap = self.webservice.get(snap["self_link"]).jsonBody()
+ self.assertEqual(
+ [builds[1], builds[0], builds[2], builds[3]],
+ self.getCollectionLinks(snap, "builds"))
+ self.assertEqual(
+ [builds[1], builds[0]],
+ self.getCollectionLinks(snap, "completed_builds"))
+ self.assertEqual(
+ builds[2:], self.getCollectionLinks(snap, "pending_builds"))
+
+ def test_query_count(self):
+ # Snap has a reasonable query count.
+ snap = self.factory.makeSnap(registrant=self.person, owner=self.person)
+ url = api_url(snap)
+ logout()
+ store = Store.of(snap)
+ store.flush()
+ store.invalidate()
+ with StormStatementRecorder() as recorder:
+ self.webservice.get(url)
+ self.assertThat(recorder, HasQueryCount(Equals(15)))
=== modified file 'lib/lp/snappy/tests/test_snapbuild.py'
--- lib/lp/snappy/tests/test_snapbuild.py 2015-07-23 16:45:08 +0000
+++ lib/lp/snappy/tests/test_snapbuild.py 2015-07-23 16:45:08 +0000
@@ -5,17 +5,32 @@
__metaclass__ = type
-from datetime import timedelta
+from datetime import (
+ datetime,
+ timedelta,
+ )
+from urllib2 import (
+ HTTPError,
+ urlopen,
+ )
+import pytz
from zope.component import getUtility
from zope.security.proxy import removeSecurityProxy
+from zope.testbrowser.browser import Browser
+from zope.testbrowser.testing import PublisherMechanizeBrowser
from lp.app.errors import NotFoundError
+from lp.app.interfaces.launchpad import ILaunchpadCelebrities
from lp.buildmaster.enums import BuildStatus
from lp.buildmaster.interfaces.buildqueue import IBuildQueue
from lp.buildmaster.interfaces.packagebuild import IPackageBuild
+from lp.buildmaster.interfaces.processor import IProcessorSet
from lp.registry.enums import PersonVisibility
+from lp.services.config import config
from lp.services.features.testing import FeatureFixture
+from lp.services.librarian.browser import ProxiedLibraryFileAlias
+from lp.services.webapp.interfaces import OAuthPermission
from lp.snappy.interfaces.snap import (
SNAP_FEATURE_FLAG,
SnapFeatureDisabled,
@@ -26,10 +41,19 @@
)
from lp.soyuz.enums import ArchivePurpose
from lp.testing import (
+ ANONYMOUS,
+ api_url,
+ login,
+ logout,
person_logged_in,
TestCaseWithFactory,
)
-from lp.testing.layers import LaunchpadZopelessLayer
+from lp.testing.layers import (
+ LaunchpadFunctionalLayer,
+ LaunchpadZopelessLayer,
+ )
+from lp.testing.mail_helpers import pop_notifications
+from lp.testing.pages import webservice_for_person
class TestSnapBuildFeatureFlag(TestCaseWithFactory):
@@ -224,3 +248,179 @@
def test_getByBuildFarmJobs_works_empty(self):
self.assertContentEqual(
[], getUtility(ISnapBuildSet).getByBuildFarmJobs([]))
+
+
+class NonRedirectingMechanizeBrowser(PublisherMechanizeBrowser):
+ """A `mechanize.Browser` that does not handle redirects."""
+
+ default_features = [
+ feature for feature in PublisherMechanizeBrowser.default_features
+ if feature != "_redirect"]
+
+
+class TestSnapBuildWebservice(TestCaseWithFactory):
+
+ layer = LaunchpadFunctionalLayer
+
+ def setUp(self):
+ super(TestSnapBuildWebservice, self).setUp()
+ self.useFixture(FeatureFixture({SNAP_FEATURE_FLAG: u"on"}))
+ self.person = self.factory.makePerson()
+ self.webservice = webservice_for_person(
+ self.person, permission=OAuthPermission.WRITE_PRIVATE)
+ self.webservice.default_api_version = "devel"
+ login(ANONYMOUS)
+
+ def getURL(self, obj):
+ return self.webservice.getAbsoluteUrl(api_url(obj))
+
+ def test_properties(self):
+ # The basic properties of a SnapBuild are sensible.
+ db_build = self.factory.makeSnapBuild(
+ requester=self.person,
+ date_created=datetime(2014, 04, 25, 10, 38, 0, tzinfo=pytz.UTC))
+ build_url = api_url(db_build)
+ logout()
+ build = self.webservice.get(build_url).jsonBody()
+ with person_logged_in(self.person):
+ self.assertEqual(self.getURL(self.person), build["requester_link"])
+ self.assertEqual(self.getURL(db_build.snap), build["snap_link"])
+ self.assertEqual(
+ self.getURL(db_build.archive), build["archive_link"])
+ self.assertEqual(
+ self.getURL(db_build.distro_arch_series),
+ build["distro_arch_series_link"])
+ self.assertEqual("Release", build["pocket"])
+ self.assertIsNone(build["score"])
+ self.assertFalse(build["can_be_rescored"])
+ self.assertFalse(build["can_be_cancelled"])
+
+ def test_public(self):
+ # A SnapBuild with a public Snap and archive is itself public.
+ db_build = self.factory.makeSnapBuild()
+ build_url = api_url(db_build)
+ unpriv_webservice = webservice_for_person(
+ self.factory.makePerson(), permission=OAuthPermission.WRITE_PUBLIC)
+ unpriv_webservice.default_api_version = "devel"
+ logout()
+ self.assertEqual(200, self.webservice.get(build_url).status)
+ self.assertEqual(200, unpriv_webservice.get(build_url).status)
+
+ def test_private_snap(self):
+ # A SnapBuild with a private Snap is private.
+ db_team = self.factory.makeTeam(
+ owner=self.person, visibility=PersonVisibility.PRIVATE)
+ with person_logged_in(self.person):
+ db_build = self.factory.makeSnapBuild(
+ requester=self.person, owner=db_team)
+ build_url = api_url(db_build)
+ unpriv_webservice = webservice_for_person(
+ self.factory.makePerson(), permission=OAuthPermission.WRITE_PUBLIC)
+ unpriv_webservice.default_api_version = "devel"
+ logout()
+ self.assertEqual(200, self.webservice.get(build_url).status)
+ # 404 since we aren't allowed to know that the private team exists.
+ self.assertEqual(404, unpriv_webservice.get(build_url).status)
+
+ def test_private_archive(self):
+ # A SnapBuild with a private archive is private.
+ db_archive = self.factory.makeArchive(owner=self.person, private=True)
+ with person_logged_in(self.person):
+ db_build = self.factory.makeSnapBuild(archive=db_archive)
+ build_url = api_url(db_build)
+ unpriv_webservice = webservice_for_person(
+ self.factory.makePerson(), permission=OAuthPermission.WRITE_PUBLIC)
+ unpriv_webservice.default_api_version = "devel"
+ logout()
+ self.assertEqual(200, self.webservice.get(build_url).status)
+ self.assertEqual(401, unpriv_webservice.get(build_url).status)
+
+ def test_cancel(self):
+ # The owner of a build can cancel it.
+ db_build = self.factory.makeSnapBuild(requester=self.person)
+ db_build.queueBuild()
+ build_url = api_url(db_build)
+ unpriv_webservice = webservice_for_person(
+ self.factory.makePerson(), permission=OAuthPermission.WRITE_PUBLIC)
+ unpriv_webservice.default_api_version = "devel"
+ logout()
+ build = self.webservice.get(build_url).jsonBody()
+ self.assertTrue(build["can_be_cancelled"])
+ response = unpriv_webservice.named_post(build["self_link"], "cancel")
+ self.assertEqual(401, response.status)
+ response = self.webservice.named_post(build["self_link"], "cancel")
+ self.assertEqual(200, response.status)
+ build = self.webservice.get(build_url).jsonBody()
+ self.assertFalse(build["can_be_cancelled"])
+ with person_logged_in(self.person):
+ self.assertEqual(BuildStatus.CANCELLED, db_build.status)
+
+ def test_rescore(self):
+ # Buildd administrators can rescore builds.
+ db_build = self.factory.makeSnapBuild(requester=self.person)
+ db_build.queueBuild()
+ build_url = api_url(db_build)
+ buildd_admin = self.factory.makePerson(
+ member_of=[getUtility(ILaunchpadCelebrities).buildd_admin])
+ buildd_admin_webservice = webservice_for_person(
+ buildd_admin, permission=OAuthPermission.WRITE_PUBLIC)
+ buildd_admin_webservice.default_api_version = "devel"
+ logout()
+ build = self.webservice.get(build_url).jsonBody()
+ self.assertEqual(2505, build["score"])
+ self.assertTrue(build["can_be_rescored"])
+ response = self.webservice.named_post(
+ build["self_link"], "rescore", score=5000)
+ self.assertEqual(401, response.status)
+ response = buildd_admin_webservice.named_post(
+ build["self_link"], "rescore", score=5000)
+ self.assertEqual(200, response.status)
+ build = self.webservice.get(build_url).jsonBody()
+ self.assertEqual(5000, build["score"])
+
+ def makeNonRedirectingBrowser(self, person):
+ # The test browser can only work with the appserver, not the
+ # librarian, so follow one layer of redirection through the
+ # appserver and then ask the librarian for the real file.
+ browser = Browser(mech_browser=NonRedirectingMechanizeBrowser())
+ browser.handleErrors = False
+ with person_logged_in(person):
+ browser.addHeader(
+ "Authorization", "Basic %s:test" % person.preferredemail.email)
+ return browser
+
+ def assertCanOpenRedirectedUrl(self, browser, url):
+ redirection = self.assertRaises(HTTPError, browser.open, url)
+ self.assertEqual(303, redirection.code)
+ urlopen(redirection.hdrs["Location"]).close()
+
+ def test_logs(self):
+ # API clients can fetch the build and upload logs.
+ db_build = self.factory.makeSnapBuild(requester=self.person)
+ db_build.setLog(self.factory.makeLibraryFileAlias("buildlog.txt.gz"))
+ db_build.storeUploadLog("uploaded")
+ build_url = api_url(db_build)
+ logout()
+ build = self.webservice.get(build_url).jsonBody()
+ browser = self.makeNonRedirectingBrowser(self.person)
+ self.assertIsNotNone(build["build_log_url"])
+ self.assertCanOpenRedirectedUrl(browser, build["build_log_url"])
+ self.assertIsNotNone(build["upload_log_url"])
+ self.assertCanOpenRedirectedUrl(browser, build["upload_log_url"])
+
+ def test_getFileUrls(self):
+ # API clients can fetch files attached to builds.
+ db_build = self.factory.makeSnapBuild(requester=self.person)
+ db_files = [
+ self.factory.makeSnapFile(snapbuild=db_build) for i in range(2)]
+ build_url = api_url(db_build)
+ file_urls = [
+ ProxiedLibraryFileAlias(file.libraryfile, db_build).http_url
+ for file in db_files]
+ logout()
+ response = self.webservice.named_get(build_url, "getFileUrls")
+ self.assertEqual(200, response.status)
+ self.assertContentEqual(file_urls, response.jsonBody())
+ browser = self.makeNonRedirectingBrowser(self.person)
+ for file_url in file_urls:
+ self.assertCanOpenRedirectedUrl(browser, file_url)
Follow ups