Merge lp:~jtv/launchpad/fake-librarian into lp:launchpad

Proposed by Jeroen T. Vermeulen on 2010-07-26
Status: Merged
Approved by: Jeroen T. Vermeulen on 2010-08-16
Approved revision: no longer in the source branch.
Merged at revision: 11380
Proposed branch: lp:~jtv/launchpad/fake-librarian
Merge into: lp:launchpad
Diff against target: 600 lines (+386/-34)
7 files modified
lib/canonical/launchpad/browser/librarian.py (+3/-2)
lib/canonical/librarian/client.py (+18/-10)
lib/canonical/librarian/interfaces.py (+21/-7)
lib/canonical/librarian/storage.py (+22/-13)
lib/canonical/librarian/web.py (+2/-2)
lib/lp/testing/fakelibrarian.py (+201/-0)
lib/lp/testing/tests/test_fakelibrarian.py (+119/-0)
To merge this branch: bzr merge lp:~jtv/launchpad/fake-librarian
Reviewer Review Type Date Requested Status
Gary Poster (community) Approve on 2010-08-16
Curtis Hovey (community) code 2010-07-26 Approve on 2010-08-09
Review via email: mp+30907@code.launchpad.net

Commit Message

Fake Librarian.

Description of the Change

= Fake Librarian =

This implements a fake librarian that we could use in tests.

The real librarian runs as a separate daemon that needs to be started and managed during test runs, taken up and down in some places (which takes ages), and sometimes manually killed in order to get things running again. It also writes files to the filesystem. In other words, it's lots of stuff we'd rather not have too much of in test runs.

In this branch I propose a simple fake librarian that could replace the real thing in many tests. It runs purely in-process and stores non-persistently, so it has none of that stuff that we don't like. Of course that also means it won't work for tests that fire off sub-processes.

To splice the fake librarian into a test's environment, e.g. in a cut-down LaunchpadLayer, we would ideally:
 * Create one global FakeLibrarian object.
 * Override the ILibrarianClient utility with the fake librarian.
 * Override the ILibraryFileAliasSet utility with the fake librarian.
 * Register the fake librarian as a transaction synchronizer.

The test would then use the fake librarian instead of the real one.

URLs and remote addition of files are not supported for now, so the fake librarian would be of little use in browser tests for now.

You'll also note that the fake librarian keeps track of whether a file has been committed to storage. This is an idiosyncrasy of the real librarian: any newly added files remain inaccessible until you commit. Stuart noted that it would be best to simulate the real librarian's behaviour here so that we don't hide nasty transaction-boundary bugs. Unlike the real librarian the fake one gives a reasonably helpful error message in this case. You'll also have the option of simulating a commit without actually forcing one on the test database.

The fake librarian uses the real database objects, just like the original. This means that it should still work when application code goes straight to the database, e.g. by joining or prejoining LibraryFileAlias and/or LibraryFileContent.

At the time of writing I'm sure we'll still want to play with this a bit before considering it for use in our test suite. I'm filing this as a Work-in-Progress MP.

Test with:
{{{
./bin/test -vvc -m lp.testing.tests.test_fakelibrarian
}}}

No lint.

Jeroen

To post a comment you must log in.
Curtis Hovey (sinzui) wrote :

Thanks to doing this. I am very excited by this. I think it will help the test setup for project release files for the registry.

I have one concern. This will not work our of proc the way it is demonsdtrated. Like config and zcml registryations that happing in tests now, they do not affect an out-of-proc process. I think engineers should know via a comment in the FackLibrarian that it operates only in the current proc.

review: Approve (code)
Gary Poster (gary) wrote :

The changes since Curtis' approval look very good to me.

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'lib/canonical/launchpad/browser/librarian.py'
2--- lib/canonical/launchpad/browser/librarian.py 2010-08-04 20:13:18 +0000
3+++ lib/canonical/launchpad/browser/librarian.py 2010-08-17 21:08:49 +0000
4@@ -32,7 +32,7 @@
5 IWebBrowserOriginatingRequest)
6 from canonical.launchpad.webapp.url import urlappend
7 from canonical.lazr.utils import get_current_browser_request
8-from canonical.librarian.client import quote
9+from canonical.librarian.client import url_path_quote
10 from canonical.librarian.interfaces import LibrarianServerError
11 from canonical.librarian.utils import filechunks, guess_librarian_encoding
12
13@@ -216,5 +216,6 @@
14 parent_url = canonical_url(self.parent, request=request)
15 traversal_url = urlappend(parent_url, '+files')
16 url = urlappend(
17- traversal_url, quote(self.context.filename.encode('utf-8')))
18+ traversal_url,
19+ url_path_quote(self.context.filename.encode('utf-8')))
20 return url
21
22=== modified file 'lib/canonical/librarian/client.py'
23--- lib/canonical/librarian/client.py 2010-08-03 17:20:07 +0000
24+++ lib/canonical/librarian/client.py 2010-08-17 21:08:49 +0000
25@@ -1,4 +1,4 @@
26-# Copyright 2009 Canonical Ltd. This software is licensed under the
27+# Copyright 2009-2010 Canonical Ltd. This software is licensed under the
28 # GNU Affero General Public License version 3 (see the file LICENSE).
29
30 __metaclass__ = type
31@@ -6,9 +6,10 @@
32 __all__ = [
33 'FileDownloadClient',
34 'FileUploadClient',
35+ 'get_libraryfilealias_download_path',
36 'LibrarianClient',
37 'RestrictedLibrarianClient',
38- 'quote',
39+ 'url_path_quote',
40 ]
41
42
43@@ -34,6 +35,18 @@
44 LIBRARIAN_SERVER_DEFAULT_TIMEOUT, LibrarianServerError, UploadFailed)
45
46
47+def url_path_quote(filename):
48+ """Quote `filename` for use in a URL."""
49+ # XXX RobertCollins 2004-09-21: Perhaps filenames with / in them
50+ # should be disallowed?
51+ return urllib.quote(filename).replace('/', '%2F')
52+
53+
54+def get_libraryfilealias_download_path(aliasID, filename):
55+ """Download path for a given `LibraryFileAlias` id and filename."""
56+ return '/%d/%s' % (int(aliasID), url_path_quote(filename))
57+
58+
59 class FileUploadClient:
60 """Simple blocking client for uploading to the librarian."""
61
62@@ -226,18 +239,12 @@
63 status, ids = response.split()
64 contentID, aliasID = ids.split('/', 1)
65
66- path = '/%d/%s' % (int(aliasID), quote(name))
67+ path = get_libraryfilealias_download_path(aliasID, name)
68 return urljoin(self.download_url, path)
69 finally:
70 self._close()
71
72
73-def quote(s):
74- # XXX: Robert Collins 2004-09-21: Perhaps filenames with / in them
75- # should be disallowed?
76- return urllib.quote(s).replace('/', '%2F')
77-
78-
79 class _File:
80 """A wrapper around a file like object that has security assertions"""
81
82@@ -300,7 +307,8 @@
83 'Alias %d cannot be downloaded from this client.' % aliasID)
84 if lfa.deleted:
85 return None
86- return '/%d/%s' % (aliasID, quote(lfa.filename.encode('utf-8')))
87+ return get_libraryfilealias_download_path(
88+ aliasID, lfa.filename.encode('utf-8'))
89
90 def getURLForAlias(self, aliasID):
91 """Returns the url for talking to the librarian about the given
92
93=== modified file 'lib/canonical/librarian/interfaces.py'
94--- lib/canonical/librarian/interfaces.py 2010-04-20 14:27:26 +0000
95+++ lib/canonical/librarian/interfaces.py 2010-08-17 21:08:49 +0000
96@@ -1,9 +1,18 @@
97-# Copyright 2009 Canonical Ltd. This software is licensed under the
98+# Copyright 2009-2010 Canonical Ltd. This software is licensed under the
99 # GNU Affero General Public License version 3 (see the file LICENSE).
100
101 # PyLint doesn't grok Zope interfaces.
102 # pylint: disable-msg=E0213
103 __metaclass__ = type
104+__all__ = [
105+ 'DownloadFailed',
106+ 'IFileUploadClient',
107+ 'ILibrarianClient',
108+ 'IRestrictedLibrarianClient',
109+ 'LibrarianServerError',
110+ 'LIBRARIAN_SERVER_DEFAULT_TIMEOUT',
111+ 'UploadFailed',
112+ ]
113
114 import signal
115
116@@ -35,14 +44,18 @@
117 # LibrarianServerError.
118 LIBRARIAN_SERVER_DEFAULT_TIMEOUT = 5
119
120+
121 class IFileUploadClient(Interface):
122+ """Upload API for the Librarian client."""
123+
124 def addFile(name, size, file, contentType, expires=None):
125 """Add a file to the librarian.
126
127- :param name: Name to store the file as
128- :param size: Size of the file
129- :param file: File-like object with the content in it
130- :param expires: Expiry time of file, or None to keep until unreferenced
131+ :param name: Name to store the file as.
132+ :param size: Size of the file.
133+ :param file: File-like object with the content in it.
134+ :param expires: Expiry time of file, or None to keep until
135+ unreferenced.
136
137 :raises UploadFailed: If the server rejects the upload for some reason
138
139@@ -74,6 +87,8 @@
140
141
142 class IFileDownloadClient(Interface):
143+ """Download API for the Librarian client."""
144+
145 def getURLForAlias(aliasID):
146 """Returns the URL to the given file"""
147
148@@ -87,7 +102,7 @@
149 LibrarianServerError is raised.
150 :return: A file-like object to read the file contents from.
151 :raises DownloadFailed: If the alias is not found.
152- :raises LibrarianServerError: If the librarain server is
153+ :raises LibrarianServerError: If the librarian server is
154 unreachable or returns an 5xx HTTPError.
155 """
156
157@@ -98,4 +113,3 @@
158
159 class IRestrictedLibrarianClient(ILibrarianClient):
160 """A version of the client that connects to a restricted librarian."""
161-
162
163=== modified file 'lib/canonical/librarian/storage.py'
164--- lib/canonical/librarian/storage.py 2010-02-09 00:17:40 +0000
165+++ lib/canonical/librarian/storage.py 2010-08-17 21:08:49 +0000
166@@ -1,4 +1,4 @@
167-# Copyright 2009 Canonical Ltd. This software is licensed under the
168+# Copyright 2009-2010 Canonical Ltd. This software is licensed under the
169 # GNU Affero General Public License version 3 (see the file LICENSE).
170
171 __metaclass__ = type
172@@ -15,11 +15,18 @@
173 IStoreSelector, MAIN_STORE, DEFAULT_FLAVOR)
174 from canonical.librarian.db import write_transaction
175
176-__all__ = ['DigestMismatchError', 'LibrarianStorage', 'LibraryFileUpload',
177- 'DuplicateFileIDError', 'WrongDatabaseError',
178- # _relFileLocation needed by other modules in this package.
179- # Listed here to keep the import facist happy
180- '_relFileLocation', '_sameFile']
181+__all__ = [
182+ 'DigestMismatchError',
183+ 'LibrarianStorage',
184+ 'LibraryFileUpload',
185+ 'DuplicateFileIDError',
186+ 'WrongDatabaseError',
187+ # _relFileLocation needed by other modules in this package.
188+ # Listed here to keep the import fascist happy
189+ '_relFileLocation',
190+ '_sameFile',
191+ ]
192+
193
194 class DigestMismatchError(Exception):
195 """The given digest doesn't match the SHA-1 digest of the file."""
196@@ -31,6 +38,7 @@
197
198 class WrongDatabaseError(Exception):
199 """The client's database name doesn't match our database."""
200+
201 def __init__(self, clientDatabaseName, serverDatabaseName):
202 Exception.__init__(self, clientDatabaseName, serverDatabaseName)
203 self.clientDatabaseName = clientDatabaseName
204@@ -40,8 +48,8 @@
205 class LibrarianStorage:
206 """Blob storage.
207
208- This manages the actual storage of files on disk and the record of those in
209- the database; it has nothing to do with the network interface to those
210+ This manages the actual storage of files on disk and the record of those
211+ in the database; it has nothing to do with the network interface to those
212 files.
213 """
214
215@@ -109,11 +117,11 @@
216 # the file really is removed or renamed, and can't possibly be
217 # left in limbo
218 os.remove(self.tmpfilepath)
219- raise DigestMismatchError, (self.srcDigest, dstDigest)
220+ raise DigestMismatchError(self.srcDigest, dstDigest)
221
222 try:
223- # If the client told us the name database of the database
224- # its using, check that it matches
225+ # If the client told us the name of the database it's using,
226+ # check that it matches.
227 if self.databaseName is not None:
228 store = getUtility(IStoreSelector).get(
229 MAIN_STORE, DEFAULT_FLAVOR)
230@@ -122,7 +130,8 @@
231 if self.databaseName != databaseName:
232 raise WrongDatabaseError(self.databaseName, databaseName)
233
234- self.debugLog.append('database name %r ok' % (self.databaseName,))
235+ self.debugLog.append(
236+ 'database name %r ok' % (self.databaseName, ))
237 # If we haven't got a contentID, we need to create one and return
238 # it to the client.
239 if self.contentID is None:
240@@ -135,7 +144,7 @@
241 else:
242 contentID = self.contentID
243 aliasID = None
244- self.debugLog.append('received contentID: %r' % (contentID,))
245+ self.debugLog.append('received contentID: %r' % (contentID, ))
246
247 except:
248 # Abort transaction and re-raise
249
250=== modified file 'lib/canonical/librarian/web.py'
251--- lib/canonical/librarian/web.py 2009-11-22 22:27:12 +0000
252+++ lib/canonical/librarian/web.py 2010-08-17 21:08:49 +0000
253@@ -9,7 +9,7 @@
254 from twisted.web import resource, static, util, server, proxy
255 from twisted.internet.threads import deferToThread
256
257-from canonical.librarian.client import quote
258+from canonical.librarian.client import url_path_quote
259 from canonical.librarian.db import read_transaction, write_transaction
260 from canonical.librarian.utils import guess_librarian_encoding
261
262@@ -163,7 +163,7 @@
263 @read_transaction
264 def _matchingAliases(self, digest):
265 library = self.storage.library
266- matches = ['%s/%s' % (aID, quote(aName))
267+ matches = ['%s/%s' % (aID, url_path_quote(aName))
268 for fID in library.lookupBySHA1(digest)
269 for aID, aName, aType in library.getAliases(fID)]
270 return matches
271
272=== added file 'lib/lp/testing/fakelibrarian.py'
273--- lib/lp/testing/fakelibrarian.py 1970-01-01 00:00:00 +0000
274+++ lib/lp/testing/fakelibrarian.py 2010-08-17 21:08:49 +0000
275@@ -0,0 +1,201 @@
276+# Copyright 2010 Canonical Ltd. This software is licensed under the
277+# GNU Affero General Public License version 3 (see the file LICENSE).
278+
279+"""Fake, in-process implementation of the Librarian API.
280+
281+This works in-process only. It does not support exchange of files
282+between processes, or URL access. Nor will it completely support all
283+details of the Librarian interface. But where it's enough, this
284+provides a simple and fast alternative to the full Librarian in unit
285+tests.
286+"""
287+
288+__metaclass__ = type
289+__all__ = [
290+ 'FakeLibrarian',
291+ ]
292+
293+import hashlib
294+from StringIO import StringIO
295+from urlparse import urljoin
296+
297+import zope.component
298+from zope.interface import implements
299+import transaction
300+from transaction.interfaces import ISynchronizer
301+
302+from canonical.config import config
303+
304+from canonical.launchpad.database.librarian import (
305+ LibraryFileContent, LibraryFileAlias)
306+from canonical.launchpad.interfaces.librarian import ILibraryFileAliasSet
307+from canonical.librarian.client import get_libraryfilealias_download_path
308+from canonical.librarian.interfaces import (
309+ ILibrarianClient,
310+ LIBRARIAN_SERVER_DEFAULT_TIMEOUT)
311+
312+
313+class InstrumentedLibraryFileAlias(LibraryFileAlias):
314+ """A `ILibraryFileAlias` implementation that fakes library access."""
315+
316+ file_committed = False
317+
318+ def checkCommitted(self):
319+ """Raise an error if this file has not been committed yet."""
320+ if not self.file_committed:
321+ raise LookupError(
322+ "Attempting to retrieve file '%s' from the fake "
323+ "librarian, but the file has not yet been committed to "
324+ "storage." % self.filename)
325+
326+ def open(self, timeout=LIBRARIAN_SERVER_DEFAULT_TIMEOUT):
327+ self.checkCommitted()
328+ self._datafile = StringIO(self.content_string)
329+
330+ def read(self, chunksize=None, timeout=LIBRARIAN_SERVER_DEFAULT_TIMEOUT):
331+ return self._datafile.read(chunksize)
332+
333+
334+class FakeLibrarian(object):
335+ """A fake, in-process Librarian.
336+
337+ This takes the role of both the librarian client and the LibraryFileAlias
338+ utility.
339+ """
340+ provided_utilities = [ILibrarianClient, ILibraryFileAliasSet]
341+ implements(ISynchronizer, *provided_utilities)
342+
343+ installed_as_librarian = False
344+
345+ def installAsLibrarian(self):
346+ """Install this `FakeLibrarian` as the default Librarian."""
347+ if self.installed_as_librarian:
348+ return
349+
350+ transaction.manager.registerSynch(self)
351+
352+ # Original utilities that need to be restored.
353+ self.original_utilities = {}
354+
355+ site_manager = zope.component.getGlobalSiteManager()
356+ for utility in self.provided_utilities:
357+ original = zope.component.getUtility(utility)
358+ if site_manager.unregisterUtility(original, utility):
359+ # We really disabled a utility, so remember to restore
360+ # it later. (Alternatively, the utility object might
361+ # implement an interface that extends the utility one,
362+ # in which case we should not restore it.)
363+ self.original_utilities[utility] = original
364+ zope.component.provideUtility(self, utility)
365+
366+ self.installed_as_librarian = True
367+
368+ def uninstall(self):
369+ """Un-install this `FakeLibrarian` as the default Librarian."""
370+ if not self.installed_as_librarian:
371+ return
372+
373+ transaction.manager.unregisterSynch(self)
374+
375+ site_manager = zope.component.getGlobalSiteManager()
376+ for utility in reversed(self.provided_utilities):
377+ site_manager.unregisterUtility(self, utility)
378+ original_utility = self.original_utilities.get(utility)
379+ if original_utility is not None:
380+ # We disabled a utility to get here; restore the
381+ # original. We do not do this for utilities that were
382+ # implemented through interface inheritance, because in
383+ # that case we would never have unregistered anything in
384+ # the first place. Re-registering would register the
385+ # same object twice, for related but different
386+ # interfaces.
387+ zope.component.provideUtility(original_utility, utility)
388+
389+ self.installed_as_librarian = False
390+
391+ def __init__(self):
392+ self.aliases = {}
393+ self.download_url = config.librarian.download_url
394+
395+ def addFile(self, name, size, file, contentType, expires=None):
396+ """See `IFileUploadClient`."""
397+ content = file.read()
398+ real_size = len(content)
399+ if real_size != size:
400+ raise AssertionError(
401+ "Uploading '%s' to the fake librarian with incorrect "
402+ "size %d; actual size is %d." % (name, size, real_size))
403+
404+ file_ref = self._makeLibraryFileContent(content)
405+ alias = self._makeAlias(file_ref.id, name, content, contentType)
406+ self.aliases[alias.id] = alias
407+
408+ return alias.id
409+
410+ def remoteAddFile(self, name, size, file, contentType, expires=None):
411+ """See `IFileUploadClient`."""
412+ return NotImplementedError()
413+
414+ def getURLForAlias(self, aliasID):
415+ """See `IFileDownloadClient`."""
416+ alias = self.aliases.get(aliasID)
417+ path = get_libraryfilealias_download_path(aliasID, alias.filename)
418+ return urljoin(self.download_url, path)
419+
420+ def getFileByAlias(self, aliasID,
421+ timeout=LIBRARIAN_SERVER_DEFAULT_TIMEOUT):
422+ """See `IFileDownloadClient`."""
423+ alias = self[aliasID]
424+ alias.checkCommitted()
425+ return StringIO(alias.content_string)
426+
427+ def _makeAlias(self, file_id, name, content, content_type):
428+ """Create a `LibraryFileAlias`."""
429+ alias = InstrumentedLibraryFileAlias(
430+ contentID=file_id, filename=name, mimetype=content_type)
431+ alias.content_string = content
432+ return alias
433+
434+ def _makeLibraryFileContent(self, content):
435+ """Create a `LibraryFileContent`."""
436+ size = len(content)
437+ sha1 = hashlib.sha1(content).hexdigest()
438+ md5 = hashlib.md5(content).hexdigest()
439+
440+ content_object = LibraryFileContent(filesize=size, sha1=sha1, md5=md5)
441+ return content_object
442+
443+ def create(self, name, size, file, contentType, expires=None,
444+ debugID=None, restricted=False):
445+ "See `ILibraryFileAliasSet`."""
446+ return self.addFile(
447+ name, size, file, contentType, expires=expires, debugID=debugID)
448+
449+ def __getitem__(self, key):
450+ "See `ILibraryFileAliasSet`."""
451+ alias = self.aliases.get(key)
452+ if alias is None:
453+ raise LookupError(
454+ "Attempting to retrieve file alias %d from the fake "
455+ "librarian, who has never heard of it." % key)
456+ return alias
457+
458+ def findBySHA1(self, sha1):
459+ "See `ILibraryFileAliasSet`."""
460+ for alias in self.aliases.itervalues():
461+ if alias.content.sha1 == sha1:
462+ return alias
463+
464+ return None
465+
466+ def beforeCompletion(self, txn):
467+ """See `ISynchronizer`."""
468+
469+ def afterCompletion(self, txn):
470+ """See `ISynchronizer`."""
471+ # Note that all files have been committed to storage.
472+ for alias in self.aliases.itervalues():
473+ alias.file_committed = True
474+
475+ def newTransaction(self, txn):
476+ """See `ISynchronizer`."""
477
478=== added file 'lib/lp/testing/tests/test_fakelibrarian.py'
479--- lib/lp/testing/tests/test_fakelibrarian.py 1970-01-01 00:00:00 +0000
480+++ lib/lp/testing/tests/test_fakelibrarian.py 2010-08-17 21:08:49 +0000
481@@ -0,0 +1,119 @@
482+# Copyright 2010 Canonical Ltd. This software is licensed under the
483+# GNU Affero General Public License version 3 (see the file LICENSE).
484+
485+"""Test the fake librarian."""
486+
487+__metaclass__ = type
488+
489+from StringIO import StringIO
490+
491+import transaction
492+from transaction.interfaces import ISynchronizer
493+from zope.component import getUtility
494+
495+from canonical.launchpad.database.librarian import LibraryFileAliasSet
496+from canonical.launchpad.interfaces.librarian import ILibraryFileAliasSet
497+from canonical.librarian.client import LibrarianClient
498+from canonical.librarian.interfaces import ILibrarianClient
499+from canonical.launchpad.webapp.testing import verifyObject
500+from canonical.testing import (
501+ DatabaseFunctionalLayer, LaunchpadFunctionalLayer)
502+from lp.testing import TestCaseWithFactory
503+from lp.testing.fakelibrarian import FakeLibrarian
504+
505+
506+class LibraryAccessScenarioMixin:
507+ """Simple Librarian uses that can be serviced by the FakeLibrarian.
508+
509+ This tests the subset of the Librarian interface that is also
510+ implemented by the FakeLibrarian. If your test needs anything more
511+ than this, then you want the real Librarian.
512+ """
513+
514+ def _storeFile(self):
515+ """Store a file in the `FakeLibrarian`.
516+
517+ :return: Tuple of filename, file contents, alias id.
518+ """
519+ name = self.factory.getUniqueString() + '.txt'
520+ text = self.factory.getUniqueString()
521+ alias_id = getUtility(ILibrarianClient).addFile(
522+ name, len(text), StringIO(text), 'text/plain')
523+ return name, text, alias_id
524+
525+ def test_baseline(self):
526+ self.assertTrue(
527+ verifyObject(
528+ ILibrarianClient, getUtility(ILibrarianClient)))
529+ self.assertTrue(
530+ verifyObject(
531+ ILibraryFileAliasSet, getUtility(ILibraryFileAliasSet)))
532+
533+ def test_insert_retrieve(self):
534+ name, text, alias_id = self._storeFile()
535+ self.assertIsInstance(alias_id, (int, long))
536+
537+ transaction.commit()
538+
539+ library_file = getUtility(ILibrarianClient).getFileByAlias(alias_id)
540+ self.assertEqual(text, library_file.read())
541+
542+ def test_alias_set(self):
543+ name, text, alias_id = self._storeFile()
544+
545+ retrieved_alias = getUtility(ILibraryFileAliasSet)[alias_id]
546+
547+ self.assertEqual(alias_id, retrieved_alias.id)
548+ self.assertEqual(name, retrieved_alias.filename)
549+
550+ def test_read(self):
551+ name, text, alias_id = self._storeFile()
552+ transaction.commit()
553+
554+ retrieved_alias = getUtility(ILibraryFileAliasSet)[alias_id]
555+ retrieved_alias.open()
556+ self.assertEqual(text, retrieved_alias.read())
557+
558+ def test_uncommitted_file(self):
559+ name, text, alias_id = self._storeFile()
560+ retrieved_alias = getUtility(ILibraryFileAliasSet)[alias_id]
561+ self.assertRaises(LookupError, retrieved_alias.open)
562+
563+ def test_incorrect_upload_size(self):
564+ name = self.factory.getUniqueString()
565+ text = self.factory.getUniqueString()
566+ wrong_length = len(text) + 1
567+ self.assertRaises(
568+ AssertionError,
569+ getUtility(ILibrarianClient).addFile,
570+ name, wrong_length, StringIO(text), 'text/plain')
571+
572+
573+class TestFakeLibrarian(LibraryAccessScenarioMixin, TestCaseWithFactory):
574+ """Test the supported interface subset on the fake librarian."""
575+
576+ layer = DatabaseFunctionalLayer
577+
578+ def setUp(self):
579+ super(TestFakeLibrarian, self).setUp()
580+ self.fake_librarian = FakeLibrarian()
581+ self.fake_librarian.installAsLibrarian()
582+
583+ def tearDown(self):
584+ super(TestFakeLibrarian, self).tearDown()
585+ self.fake_librarian.uninstall()
586+
587+ def test_fake(self):
588+ self.assertTrue(verifyObject(ISynchronizer, self.fake_librarian))
589+ self.assertIsInstance(self.fake_librarian, FakeLibrarian)
590+
591+
592+class TestRealLibrarian(LibraryAccessScenarioMixin, TestCaseWithFactory):
593+ """Test the supported interface subset on the real librarian."""
594+
595+ layer = LaunchpadFunctionalLayer
596+
597+ def test_real(self):
598+ self.assertIsInstance(getUtility(ILibrarianClient), LibrarianClient)
599+ self.assertIsInstance(
600+ getUtility(ILibraryFileAliasSet), LibraryFileAliasSet)