diff --git a/README-uri-scheme.md b/README-uri-scheme.md index fb29ae5c..0889e7b2 100644 --- a/README-uri-scheme.md +++ b/README-uri-scheme.md @@ -1,113 +1,146 @@ URI scheme ========== Browsing namespace ------------------ ### Global To be anchored where browsing starts (e.g., at /api/1) * /revision/: show commit information + $curl http://localhost:6543/api/1/revision/18d8be353ed3480476f032475e7c233eff7371d5 + { + "revision": { + "author_email": "robot@softwareheritage.org", + "author_name": "Software Heritage", + "committer_date": "Mon, 17 Jan 2000 10:23:54 GMT", + "committer_date_offset": 0, + "committer_email": "robot@softwareheritage.org", + "committer_name": "Software Heritage", + "date": "Mon, 17 Jan 2000 10:23:54 GMT", + "date_offset": 0, + "directory": "7834ef7e7c357ce2af928115c6c6a42b7e2a44e6", + "id": "18d8be353ed3480476f032475e7c233eff7371d5", + "message": "synthetic revision message", + "metadata": { + "original_artifact": [ + { + "archive_type": "tar", + "name": "webbase-5.7.0.tar.gz", + "sha1": "147f73f369733d088b7a6fa9c4e0273dcd3c7ccd", + "sha1_git": "6a15ea8b881069adedf11feceec35588f2cfe8f1", + "sha256": "401d0df797110bea805d358b85bcc1ced29549d3d73f309d36484e7edf7bb912" + } + ] + }, + "parents": [ + null + ], + "synthetic": true, + "type": "tar" + } + } + * /directory/: show directory information (including ls) * /directory//path/to/file-or-dir: ditto, but for dir pointed by path - note: this is the same as /dir/, where is the sha1_git ID of the dir pointed by path * /content/[:]: show content information - content is specified by HASH, according to HASH_ALGO, where HASH_ALGO is one of: sha1, sha1_git, sha256. This means that several different URLs (at least one per HASH_ALGO) will point to the same content - HASH_ALGO defaults to "sha1" (?) * /release/: show release information Sample: $ curl -X GET http://localhost:6543/api/1/release/4a1b6d7dd0a923ed90156c4e2f5db030095d8e08 { "release": { "author": 1, "comment": "synthetic release message", "date": "Sat, 04 Mar 2000 07:50:35 GMT", "date_offset": 0, "id": "4a1b6d7dd0a923ed90156c4e2f5db030095d8e08", "name": "4.0.6", "revision": "5c7814ce9978d4e16f3858925b5cea611e500eec", "synthetic": true } }% * /person/: show person information * /origin/: show origin information Sample: $ curl -X GET http://localhost:6543/api/1/origin/1 { "origin": { "id": 1, "lister": null, "project": null, "type": "ftp", "url": "rsync://ftp.gnu.org/old-gnu/solfege" } }% * /project/: show project information * /organization/: show organization information ### Occurrence Origin/Branch do not contain `|` so it is used as a terminator. Origin is . Timestamp is one of: latest or an ISO8601 date (TODO: decide the time matching policy). * /directory//|/|/path/to/file-or-dir - Same as /directory/ but looking up sha1 git using origin and branch at a given timestamp * /revision//|/ - Same as /revision/ but looking up sha1 git using origin and branch at a given timestamp * /revision//| - Show all branches of origin at a given timestamp * /revision//|/| - Show all revisions (~git log) of origin and branch at a given timestamp ### Upload and search * /1/api/uploadnsearch/ Post a file's content to api. Api computes the sha1 hash and checks in the storage if such sha1 exists. Json answer: {'sha1': hexadecimal sha1, 'found': true or false} Sample: $ curl -X POST -F filename=@/path/to/file http://localhost:6543/api/1/uploadnsearch/ { "found": false, "sha1": "e95097ad2d607b4c89c1ce7ca1fef2a1e4450558" }% Search namespace ---------------- diff --git a/swh/web/ui/controller.py b/swh/web/ui/controller.py index 0b3abd22..18e60214 100644 --- a/swh/web/ui/controller.py +++ b/swh/web/ui/controller.py @@ -1,391 +1,400 @@ # Copyright (C) 2015 The Software Heritage developers # See the AUTHORS file at the top-level directory of this distribution # License: GNU Affero General Public License version 3, or any later version # See top-level LICENSE file for more information import json from flask import render_template, jsonify, request, flash from flask import make_response from swh.core.hashutil import ALGORITHMS from swh.web.ui.main import app from swh.web.ui import service from swh.web.ui.decorators import jsonp hash_filter_keys = ALGORITHMS @app.route('/') def main(): """Home page """ flash('This Web app is still work in progress, use at your own risk', 'warning') # return redirect(url_for('about')) return render_template('home.html') @app.route('/about') def about(): return render_template('about.html') @app.route('/search') def search(): """Search for hashes in swh-storage. """ q = request.args.get('q', '') env = {'q': q, 'message': '', 'found': None} try: if q: env['found'] = service.lookup_hash(q) except ValueError: env['message'] = 'Error: invalid query string' return render_template('search.html', **env) @app.route('/uploadnsearch', methods=['GET', 'POST']) def uploadnsearch(): """Upload and search for hashes in swh-storage. """ env = {'filename': None, 'message': '', 'found': None} if request.method == 'POST': file = request.files['filename'] try: filename, sha1, found = service.upload_and_search(file) message = 'The file %s with hash %s has%sbeen found.' % ( filename, sha1, ' ' if found else ' not ') env.update({ 'filename': filename, 'sha1': sha1, 'found': found, 'message': message }) except ValueError: env['message'] = 'Error: invalid query string' return render_template('upload_and_search.html', **env) @app.route('/browse/revision/') def revision(sha1_git): """Show commit information. Args: sha1_git: the revision's sha1 Returns: Revision information """ return render_template('revision.html', sha1_git=sha1_git) @app.route('/browse/directory/') def directory(sha1_git): """Show directory information. Args: sha1_git: the directory's sha1 Returns: Directory information """ return render_template('directory.html', sha1_git=sha1_git) @app.route('/browse/directory//') def directory_at_path(sha1_git, p): """Show directory information for the sha1_git at path. Args: sha1_git: the directory's sha1 path: file or directory pointed to Returns: Directory information at sha1_git + path """ return render_template('directory.html', sha1_git=sha1_git, path=p) def _origin_seen(hash, data): """Given an origin, compute a message string with the right information. Args: origin: a dictionary with keys: - origin: a dictionary with type and url keys - occurrence: a dictionary with a validity range Returns: message as a string """ if data is None: return 'Content with hash %s is unknown as of now.' % hash origin_type = data['origin_type'] origin_url = data['origin_url'] revision = data['revision'] branch = data['branch'] path = data['path'] return """The content with hash %s has been seen on origin with type '%s' at url '%s'. The revision was identified at '%s' on branch '%s'. The file's path referenced was '%s'.""" % (hash, origin_type, origin_url, revision, branch, path) @app.route('/browse/content/:') def content(hash, sha): """Show content information. Args: hash: hash according to HASH_ALGO, where HASH_ALGO is one of: sha1, sha1_git, sha256. This means that several different URLs (at least one per HASH_ALGO) will point to the same content sha: the sha with 'hash' format Returns: The content's information at sha1_git """ if hash not in hash_filter_keys: message = 'The checksum must be one of sha1, sha1_git, sha256' else: q = "%s:%s" % (hash, sha) found = service.lookup_hash(q) if not found: message = "Hash %s was not found." % hash else: origin = service.lookup_hash_origin(q) message = _origin_seen(hash, origin) return render_template('content.html', hash=hash, sha=sha, message=message) @app.route('/browse/release/') def release(sha1_git): """Show release's information. Args: sha1_git: sha1_git for this particular release Returns: Release's information """ return 'Release information at %s' % sha1_git @app.route('/browse/person/') def person(id): """Show Person's information at id. Args: id: person's unique identifier Returns: Person's information """ return 'Person information at %s' % id @app.route('/browse/origin/') def origin(id): """Show origin's information at id. Args: id: origin's unique identifier Returns: Origin's information """ return 'Origin information at %s' % id @app.route('/browse/project/') def project(id): """Show project's information at id. Args: id: project's unique identifier Returns: Project's information """ return 'Project information at %s' % id @app.route('/browse/organization/') def organization(id): """Show organization's information at id. Args: id: organization's unique identifier Returns: Organization's information """ return 'Organization information at %s' % id @app.route('/browse/directory//' '+|/' '|/') def directory_at_origin(timestamp, origin_type, origin_url, branch, path): """Show directory information at timestamp, origin-type, origin-url, branch and path. Those parameters are separated by the `|` terminator. Args: timestamp: the timestamp to look for. can be latest or some iso8601 date format. (TODO: decide the time matching policy.) origin_type: origin's type origin_url: origin's url (can contain `/`) branch: branch name which can contain `/` path: path to directory or file Returns: Directory information at the given parameters. """ return 'Directory at (%s, %s, %s, %s, %s)' % (timestamp, origin_type, origin_url, branch, path) @app.route('/browse/revision//' '+|/') def revision_at_origin_and_branch(timestamp, origin_type, origin_url, branch): """Show revision information at timestamp, origin, and branch. Those parameters are separated by the `|` terminator. Args: timestamp: the timestamp to look for. can be latest or some iso8601 date format. (TODO: decide the time matching policy.) origin_type: origin's type origin_url: origin's url (can contain `/`) branch: branch name which can contain / Returns: Revision information at the given parameters. """ return 'Revision at (ts=%s, type=%s, url=%s, branch=%s)' % (timestamp, origin_type, origin_url, branch) @app.route('/browse/revision//' '+|') def revision_at_origin(timestamp, origin_type, origin_url): """Show revision information at timestamp, origin, and branch. Those parameters are separated by the `|` terminator. Args: timestamp: the timestamp to look for. can be latest or iso8601 date format. (TODO: decide the time matching policy.) origin_type: origin's type origin_url: origin's url (can contain `/`) Returns: Revision information at the given parameters. """ return 'Revision at (timestamp=%s, type=%s, url=%s)' % (timestamp, origin_type, origin_url) @app.route('/api/1/stat/counters') @jsonp def api_stats(): """Return statistics as a JSON object""" return jsonify(service.stat_counters()) @app.errorhandler(ValueError) def value_error_as_bad_request(error): """Compute a bad request and add body as payload. """ response = make_response( 'Bad request', 400) response.headers['Content-Type'] = 'application/json' response.data = json.dumps({"error": str(error)}) return response @app.route('/api/1/search//') @jsonp def api_search(q): """Return search results as a JSON object""" return jsonify({'found': service.lookup_hash(q)}) @app.route('/api/1/origin/') @jsonp def api_origin(origin_id): """Return information about origin""" return jsonify({'origin': service.lookup_origin(origin_id)}) @app.route('/api/1/release/') @jsonp def api_release(sha1_git): """Return information about release with id sha1_git.""" return jsonify({'release': service.lookup_release(sha1_git)}) +@app.route('/api/1/revision/') +@jsonp +def api_revision(sha1_git): + """Return information about revision with id sha1_git. + + """ + return jsonify({'revision': service.lookup_revision(sha1_git)}) + + @app.route('/api/1/browse//') @jsonp def api_browse(q): """Return search results as a JSON object""" return jsonify({'origin': service.lookup_hash_origin(q)}) @app.route('/api/1/uploadnsearch/', methods=['POST']) @jsonp def api_uploadnsearch(): """Upload the file's content in the post body request. Compute the hash and determine if it exists in the storage. """ file = request.files['filename'] filename, sha1, found = service.upload_and_search(file) return jsonify({'sha1': sha1, 'filename': filename, 'found': found}) diff --git a/swh/web/ui/converters.py b/swh/web/ui/converters.py index 4cbd78e2..cb391a2b 100644 --- a/swh/web/ui/converters.py +++ b/swh/web/ui/converters.py @@ -1,56 +1,99 @@ # Copyright (C) 2015 The Software Heritage developers # See the AUTHORS file at the top-level directory of this distribution # License: GNU Affero General Public License version 3, or any later version # See top-level LICENSE file for more information from swh.core import hashutil def from_origin(origin): """Convert from an swh origin to an origin dictionary. """ new_origin = {} for key, value in origin.items(): if key == 'revision': new_origin[key] = hashutil.hash_to_hex(value) elif key == 'path': new_origin[key] = value.decode('utf-8') else: new_origin[key] = value return new_origin def from_release(release): """Convert from an swh release to a json serializable release dictionary. Args: release: Dict with the following keys - id: identifier of the revision (sha1 in bytes) - revision: identifier of the revision the release points to (sha1 in bytes) - comment: release's comment message (bytes) - name: release's name (string) - author: release's author identifier (swh's id) - synthetic: the synthetic property (boolean) Returns: Release dictionary with the following keys: - id: hexadecimal sha1 (string) - revision: hexadecimal sha1 (string) - comment: release's comment message (string) - name: release's name (string) - author: release's author identifier (swh's id) - synthetic: the synthetic property (boolean) """ new_release = {} for key, value in release.items(): if key == 'id' or key == 'revision': new_release[key] = hashutil.hash_to_hex(value) if value else None elif key == 'comment': new_release[key] = value.decode('utf-8') else: new_release[key] = value return new_release + + +def from_revision(revision): + """Convert from an SWH revision to a json serializable revision dictionary. + + Args: + revision: Dict with the following keys + - id: identifier of the revision (sha1 in bytes) + - directory: identifier of the directory the revision points to (sha1 + in bytes) + - author_name, author_email: author's revision name and email + - committer_name, committer_email: committer's revision name and email + - message: revision's message + - date, date_offset: revision's author date + - committer_date, committer_date_offset: revision's commit date + - parents: list of parents for such revision + - synthetic: revision's property nature + - type: revision's type (git, tar or dsc at the moment) + - metadata: if the revision is synthetic, this can reference dynamic + properties. + + Returns: + Revision dictionary with the same keys as inputs, only: + - sha1s are in hexadecimal strings (id, directory) + - bytes are decoded in string (author_name, committer_name, + author_email, committer_email, message) + - remaining keys are left as is + + """ + new_revision = {} + for key, value in revision.items(): + if key in ['id', 'directory']: + new_revision[key] = hashutil.hash_to_hex(value) if value else None + elif key in ['author_name', + 'committer_name', + 'author_email', + 'committer_email', + 'message']: + new_revision[key] = value.decode('utf-8') + else: + new_revision[key] = value + + return new_revision diff --git a/swh/web/ui/service.py b/swh/web/ui/service.py index a0b7031f..ae0602b1 100644 --- a/swh/web/ui/service.py +++ b/swh/web/ui/service.py @@ -1,115 +1,139 @@ # Copyright (C) 2015 The Software Heritage developers # See the AUTHORS file at the top-level directory of this distribution # License: GNU Affero General Public License version 3, or any later version # See top-level LICENSE file for more information from swh.core import hashutil from swh.web.ui import converters, main, query, upload def hash_and_search(filepath): """Hash the filepath's content as sha1, then search in storage if it exists. Args: Filepath of the file to hash and search. Returns: Tuple (hex sha1, found as True or false). The found boolean, according to whether the sha1 of the file is present or not. """ hash = hashutil.hashfile(filepath) return (hashutil.hash_to_hex(hash['sha1']), main.storage().content_exist(hash)) def upload_and_search(file): """Upload a file and compute its hash. """ tmpdir, filename, filepath = upload.save_in_upload_folder(file) try: sha1, found = None, None if filepath: sha1, found = hash_and_search(filepath) return filename, sha1, found finally: # clean up if tmpdir: upload.cleanup(tmpdir) def lookup_hash(q): """Checks if the storage contains a given content checksum Args: query string Returns: True or False, according to whether the checksum is present or not """ (algo, hash) = query.parse_hash(q) return main.storage().content_exist({algo: hash}) def lookup_hash_origin(q): """Return information about the checksum contained in the query q. Args: query string Returns: True or False, according to whether the checksum is present or not """ algo, h = query.parse_hash(q) origin = main.storage().content_find_occurrence({algo: h}) return converters.from_origin(origin) def lookup_origin(origin_id): """Return information about the origin with id origin_id. Args: origin_id as string Returns: origin information as dict. """ return main.storage().origin_get({'id': origin_id}) def lookup_release(release_sha1_git): """Return information about the release with sha1 release_sha1_git. Args: release_sha1_git: The release's sha1 as hexadecimal Returns: Release information as dict. Raises: ValueError if the identifier provided is not of sha1 nature. """ algo, hBinSha1 = query.parse_hash(release_sha1_git) if algo != 'sha1': # HACK: sha1_git really but they are both sha1... raise ValueError('Only sha1_git is supported.') res = main.storage().release_get([hBinSha1]) if res and len(res) >= 1: return converters.from_release(res[0]) return None +def lookup_revision(rev_sha1_git, rev_type='git'): + """Return information about the revision with sha1 revision_sha1_git. + + Args: + revision_sha1_git: The revision's sha1 as hexadecimal + + Returns: + Revision information as dict. + + Raises: + ValueError if the identifier provided is not of sha1 nature. + + """ + algo, hBinSha1 = query.parse_hash(rev_sha1_git) + if algo != 'sha1': # HACK: sha1_git really but they are both sha1... + raise ValueError('Only sha1_git is supported.') + + res = main.storage().revision_get([hBinSha1]) + + if res and len(res) >= 1: + return converters.from_revision(res[0]) + return None + + def stat_counters(): """Return the stat counters for Software Heritage Returns: A dict mapping textual labels to integer values. """ return main.storage().stat_counters() diff --git a/swh/web/ui/tests/test_controller.py b/swh/web/ui/tests/test_controller.py index 38aef1ef..8ea905de 100644 --- a/swh/web/ui/tests/test_controller.py +++ b/swh/web/ui/tests/test_controller.py @@ -1,220 +1,260 @@ # Copyright (C) 2015 The Software Heritage developers # See the AUTHORS file at the top-level directory of this distribution # License: GNU Affero General Public License version 3, or any later version # See top-level LICENSE file for more information import unittest import json from nose.tools import istest from unittest.mock import patch from swh.web.ui.tests import test_app class ApiTestCase(unittest.TestCase): @classmethod def setUpClass(cls): cls.app, _, _ = test_app.init_app() @istest def info(self): # when rv = self.app.get('/about') self.assertEquals(rv.status_code, 200) self.assertIn(b'About', rv.data) # @istest def search_1(self): # when rv = self.app.get('/search') self.assertEquals(rv.status_code, 200) # check this api self.assertRegexpMatches(rv.data, b'name=q value=>') # @istest def search_2(self): # when rv = self.app.get('/search?q=one-hash-to-look-for:another-one') self.assertEquals(rv.status_code, 200) # check this api self.assertRegexpMatches( rv.data, b'name=q value=one-hash-to-look-for:another-one') @patch('swh.web.ui.controller.service') @istest def api_browse(self, mock_service): # given mock_service.lookup_hash_origin.return_value = { 'origin': 'some-origin' } # when rv = self.app.get('/api/1/browse/sha1:foo/') self.assertEquals(rv.status_code, 200) self.assertEquals(rv.mimetype, 'application/json') response_data = json.loads(rv.data.decode('utf-8')) self.assertEquals(response_data, {'origin': {'origin': 'some-origin'}}) mock_service.lookup_hash_origin.assert_called_once_with('sha1:foo') @patch('swh.web.ui.controller.service') @istest def api_search(self, mock_service): # given mock_service.lookup_hash.return_value = False # when rv = self.app.get('/api/1/search/sha1:blah/') self.assertEquals(rv.status_code, 200) self.assertEquals(rv.mimetype, 'application/json') response_data = json.loads(rv.data.decode('utf-8')) self.assertEquals(response_data, {'found': False}) mock_service.lookup_hash.assert_called_once_with('sha1:blah') @patch('swh.web.ui.controller.service') @istest def api_1_stat_counters_raise_error(self, mock_service): # given mock_service.stat_counters.side_effect = ValueError( 'voluntary error to check the bad request middleware.') # when rv = self.app.get('/api/1/stat/counters') # then self.assertEquals(rv.status_code, 400) self.assertEquals(rv.mimetype, 'application/json') response_data = json.loads(rv.data.decode('utf-8')) self.assertEquals(response_data, { 'error': 'voluntary error to check the bad request middleware.'}) @patch('swh.web.ui.controller.service') @istest def api_1_stat_counters(self, mock_service): # given mock_service.stat_counters.return_value = { "content": 1770830, "directory": 211683, "directory_entry_dir": 209167, "directory_entry_file": 1807094, "directory_entry_rev": 0, "entity": 0, "entity_history": 0, "occurrence": 0, "occurrence_history": 19600, "origin": 1096, "person": 0, "release": 8584, "revision": 7792, "revision_history": 0, "skipped_content": 0 } # when rv = self.app.get('/api/1/stat/counters') response_data = json.loads(rv.data.decode('utf-8')) self.assertEquals(rv.status_code, 200) self.assertEquals(rv.mimetype, 'application/json') self.assertEquals(response_data, { "content": 1770830, "directory": 211683, "directory_entry_dir": 209167, "directory_entry_file": 1807094, "directory_entry_rev": 0, "entity": 0, "entity_history": 0, "occurrence": 0, "occurrence_history": 19600, "origin": 1096, "person": 0, "release": 8584, "revision": 7792, "revision_history": 0, "skipped_content": 0 }) mock_service.stat_counters.assert_called_once_with() @patch('swh.web.ui.controller.service') @patch('swh.web.ui.controller.request') @istest def api_uploadnsearch(self, mock_request, mock_service): # given mock_request.files = {'filename': 'simple-filename'} mock_service.upload_and_search.return_value = ( 'simple-filename', 'some-hex-sha1', False) # when rv = self.app.post('/api/1/uploadnsearch/') self.assertEquals(rv.status_code, 200) self.assertEquals(rv.mimetype, 'application/json') response_data = json.loads(rv.data.decode('utf-8')) self.assertEquals(response_data, {'filename': 'simple-filename', 'sha1': 'some-hex-sha1', 'found': False}) mock_service.upload_and_search.assert_called_once_with( 'simple-filename') @patch('swh.web.ui.controller.service') @istest def api_origin(self, mock_service): # given mock_service.lookup_origin.return_value = { 'id': 'origin-0', 'lister': 'uuid-lister-0', 'project': 'uuid-project-0', 'url': 'ftp://some/url/to/origin/0', 'type': 'ftp'} # when rv = self.app.get('/api/1/origin/origin-0') # then self.assertEquals(rv.status_code, 200) self.assertEquals(rv.mimetype, 'application/json') response_data = json.loads(rv.data.decode('utf-8')) self.assertEquals(response_data, { 'origin': { 'id': 'origin-0', 'lister': 'uuid-lister-0', 'project': 'uuid-project-0', 'url': 'ftp://some/url/to/origin/0', 'type': 'ftp' } }) @patch('swh.web.ui.controller.service') @istest def api_release(self, mock_service): # given mock_service.lookup_release.return_value = { 'id': 'release-0', 'revision': 'revision-sha1', 'author': 'author-id', } # when rv = self.app.get('/api/1/release/release-0') # then self.assertEquals(rv.status_code, 200) self.assertEquals(rv.mimetype, 'application/json') response_data = json.loads(rv.data.decode('utf-8')) self.assertEquals(response_data, { 'release': { 'id': 'release-0', 'revision': 'revision-sha1', 'author': 'author-id', } }) + + @patch('swh.web.ui.controller.service') + @istest + def api_revision(self, mock_service): + # given + mock_revision = { + 'id': '18d8be353ed3480476f032475e7c233eff7371d5', + 'directory': '7834ef7e7c357ce2af928115c6c6a42b7e2a44e6', + 'author_name': 'Software Heritage', + 'author_email': 'robot@softwareheritage.org', + 'committer_name': 'Software Heritage', + 'committer_email': 'robot@softwareheritage.org', + 'message': 'synthetic revision message', + 'date_offset': 0, + 'committer_date_offset': 0, + 'parents': [], + 'type': 'tar', + 'synthetic': True, + 'metadata': { + 'original_artifact': [{ + 'archive_type': 'tar', + 'name': 'webbase-5.7.0.tar.gz', + 'sha1': '147f73f369733d088b7a6fa9c4e0273dcd3c7ccd', + 'sha1_git': '6a15ea8b881069adedf11feceec35588f2cfe8f1', + 'sha256': '401d0df797110bea805d358b85bcc1ced29549d3d73f' + '309d36484e7edf7bb912' + }] + }, + } + mock_service.lookup_revision.return_value = mock_revision + + # when + rv = self.app.get('/api/1/revision/revision-0') + + # then + self.assertEquals(rv.status_code, 200) + self.assertEquals(rv.mimetype, 'application/json') + + response_data = json.loads(rv.data.decode('utf-8')) + self.assertEquals(response_data, {"revision": mock_revision}) diff --git a/swh/web/ui/tests/test_converters.py b/swh/web/ui/tests/test_converters.py index f969bd30..ca7582eb 100644 --- a/swh/web/ui/tests/test_converters.py +++ b/swh/web/ui/tests/test_converters.py @@ -1,103 +1,170 @@ # Copyright (C) 2015 The Software Heritage developers # See the AUTHORS file at the top-level directory of this distribution # License: GNU Affero General Public License version 3, or any later version # See top-level LICENSE file for more information import datetime import unittest from nose.tools import istest from swh.core import hashutil from swh.web.ui import converters class ConvertersTestCase(unittest.TestCase): @istest def from_origin(self): # given origin_input = { 'origin_type': 'ftp', 'origin_url': 'rsync://ftp.gnu.org/gnu/octave', 'branch': 'octave-3.4.0.tar.gz', 'revision': b'\xb0L\xaf\x10\xe9SQ`\xd9\x0e\x87KE\xaaBm\xe7b\xf1\x9f', # noqa 'path': b'octave-3.4.0/doc/interpreter/octave.html/doc_002dS_005fISREG.html' # noqa } expected_origin = { 'origin_type': 'ftp', 'origin_url': 'rsync://ftp.gnu.org/gnu/octave', 'branch': 'octave-3.4.0.tar.gz', 'revision': 'b04caf10e9535160d90e874b45aa426de762f19f', 'path': 'octave-3.4.0/doc/interpreter/octave.html/doc_002dS_005fISREG.html' # noqa } # when actual_origin = converters.from_origin(origin_input) # then self.assertEqual(actual_origin, expected_origin) @istest def from_release(self): release_input = { 'id': hashutil.hex_to_hash( 'aad23fa492a0c5fed0708a6703be875448c86884'), 'revision': hashutil.hex_to_hash( '5e46d564378afc44b31bb89f99d5675195fbdf67'), 'date': datetime.datetime(2015, 1, 1, 22, 0, 0, tzinfo=datetime.timezone.utc), 'date_offset': None, 'name': 'v0.0.1', 'comment': b'some comment on release', 'synthetic': True, } expected_release = { 'id': 'aad23fa492a0c5fed0708a6703be875448c86884', 'revision': '5e46d564378afc44b31bb89f99d5675195fbdf67', 'date': datetime.datetime(2015, 1, 1, 22, 0, 0, tzinfo=datetime.timezone.utc), 'date_offset': None, 'name': 'v0.0.1', 'comment': 'some comment on release', 'synthetic': True, } # when actual_release = converters.from_release(release_input) # then self.assertEqual(actual_release, expected_release) @istest def from_release_no_revision(self): release_input = { 'id': hashutil.hex_to_hash( 'b2171ee2bdf119cd99a7ec7eff32fa8013ef9a4e'), 'revision': None, 'date': datetime.datetime(2016, 3, 2, 10, 0, 0, tzinfo=datetime.timezone.utc), 'date_offset': 1, 'name': 'v0.1.1', 'comment': b'comment on release', 'synthetic': False, } expected_release = { 'id': 'b2171ee2bdf119cd99a7ec7eff32fa8013ef9a4e', 'revision': None, 'date': datetime.datetime(2016, 3, 2, 10, 0, 0, tzinfo=datetime.timezone.utc), 'date_offset': 1, 'name': 'v0.1.1', 'comment': 'comment on release', 'synthetic': False, } # when actual_release = converters.from_release(release_input) # then self.assertEqual(actual_release, expected_release) + + @istest + def from_revision(self): + revision_input = { + 'id': hashutil.hex_to_hash( + '18d8be353ed3480476f032475e7c233eff7371d5'), + 'directory': hashutil.hex_to_hash( + '7834ef7e7c357ce2af928115c6c6a42b7e2a44e6'), + 'author_name': b'Software Heritage', + 'author_email': b'robot@softwareheritage.org', + 'committer_name': b'Software Heritage', + 'committer_email': b'robot@softwareheritage.org', + 'message': b'synthetic revision message', + 'date': datetime.datetime(2000, 1, 17, 11, 23, 54, tzinfo=None), + 'date_offset': 0, + 'committer_date': datetime.datetime(2000, 1, 17, 11, 23, 54, + tzinfo=None), + 'committer_date_offset': 0, + 'synthetic': True, + 'type': 'tar', + 'parents': [], + 'metadata': { + 'original_artifact': [{ + 'archive_type': 'tar', + 'name': 'webbase-5.7.0.tar.gz', + 'sha1': '147f73f369733d088b7a6fa9c4e0273dcd3c7ccd', + 'sha1_git': '6a15ea8b881069adedf11feceec35588f2cfe8f1', + 'sha256': '401d0df797110bea805d358b85bcc1ced29549d3d73f' + '309d36484e7edf7bb912', + + }] + }, + } + + expected_revision = { + 'id': '18d8be353ed3480476f032475e7c233eff7371d5', + 'directory': '7834ef7e7c357ce2af928115c6c6a42b7e2a44e6', + 'author_name': 'Software Heritage', + 'author_email': 'robot@softwareheritage.org', + 'committer_name': 'Software Heritage', + 'committer_email': 'robot@softwareheritage.org', + 'message': 'synthetic revision message', + 'date': datetime.datetime(2000, 1, 17, 11, 23, 54, tzinfo=None), + 'date_offset': 0, + 'committer_date': datetime.datetime(2000, 1, 17, 11, 23, 54, + tzinfo=None), + 'committer_date_offset': 0, + 'parents': [], + 'type': 'tar', + 'synthetic': True, + 'metadata': { + 'original_artifact': [{ + 'archive_type': 'tar', + 'name': 'webbase-5.7.0.tar.gz', + 'sha1': '147f73f369733d088b7a6fa9c4e0273dcd3c7ccd', + 'sha1_git': '6a15ea8b881069adedf11feceec35588f2cfe8f1', + 'sha256': '401d0df797110bea805d358b85bcc1ced29549d3d73f' + '309d36484e7edf7bb912' + }] + }, + } + + # when + actual_revision = converters.from_revision(revision_input) + + # then + self.assertEqual(actual_revision, expected_revision) diff --git a/swh/web/ui/tests/test_service.py b/swh/web/ui/tests/test_service.py index a956fe8c..7935a680 100644 --- a/swh/web/ui/tests/test_service.py +++ b/swh/web/ui/tests/test_service.py @@ -1,237 +1,285 @@ # Copyright (C) 2015 The Software Heritage developers # See the AUTHORS file at the top-level directory of this distribution # License: GNU Affero General Public License version 3, or any later version # See top-level LICENSE file for more information +import datetime import unittest from nose.tools import istest from unittest.mock import MagicMock, patch from swh.core.hashutil import hex_to_hash from swh.web.ui import service from swh.web.ui.tests import test_app class ServiceTestCase(unittest.TestCase): @classmethod def setUpClass(cls): _, _, cls.storage = test_app.init_app() @istest def lookup_hash_does_not_exist(self): # given self.storage.content_exist = MagicMock(return_value=False) # when actual_lookup = service.lookup_hash( 'sha1:123caf10e9535160d90e874b45aa426de762f19f') # then self.assertFalse(actual_lookup) # check the function has been called with parameters self.storage.content_exist.assert_called_with({ 'sha1': hex_to_hash('123caf10e9535160d90e874b45aa426de762f19f')}) @istest def lookup_hash_exist(self): # given self.storage.content_exist = MagicMock(return_value=True) # when actual_lookup = service.lookup_hash( 'sha1:456caf10e9535160d90e874b45aa426de762f19f') # then self.assertTrue(actual_lookup) self.storage.content_exist.assert_called_with({ 'sha1': hex_to_hash('456caf10e9535160d90e874b45aa426de762f19f')}) @istest def lookup_hash_origin(self): # given self.storage.content_find_occurrence = MagicMock(return_value={ 'origin_type': 'sftp', 'origin_url': 'sftp://ftp.gnu.org/gnu/octave', 'branch': 'octavio-3.4.0.tar.gz', 'revision': b'\xb0L\xaf\x10\xe9SQ`\xd9\x0e\x87KE\xaaBm\xe7b\xf1\x9f', # noqa 'path': b'octavio-3.4.0/doc/interpreter/octave.html/doc_002dS_005fISREG.html' # noqa }) expected_origin = { 'origin_type': 'sftp', 'origin_url': 'sftp://ftp.gnu.org/gnu/octave', 'branch': 'octavio-3.4.0.tar.gz', 'revision': 'b04caf10e9535160d90e874b45aa426de762f19f', 'path': 'octavio-3.4.0/doc/interpreter/octave.html/doc' '_002dS_005fISREG.html' } # when actual_origin = service.lookup_hash_origin( 'sha1_git:456caf10e9535160d90e874b45aa426de762f19f') # then self.assertEqual(actual_origin, expected_origin) self.storage.content_find_occurrence.assert_called_with( {'sha1_git': hex_to_hash('456caf10e9535160d90e874b45aa426de762f19f')}) @istest def stat_counters(self): # given input_stats = { "content": 1770830, "directory": 211683, "directory_entry_dir": 209167, "directory_entry_file": 1807094, "directory_entry_rev": 0, "entity": 0, "entity_history": 0, "occurrence": 0, "occurrence_history": 19600, "origin": 1096, "person": 0, "release": 8584, "revision": 7792, "revision_history": 0, "skipped_content": 0 } self.storage.stat_counters = MagicMock(return_value=input_stats) # when actual_stats = service.stat_counters() # then expected_stats = input_stats self.assertEqual(actual_stats, expected_stats) self.storage.stat_counters.assert_called_with() @istest def hash_and_search(self): # given self.storage.content_exist = MagicMock(return_value=False) bhash = hex_to_hash('456caf10e9535160d90e874b45aa426de762f19f') # when with patch( 'swh.core.hashutil.hashfile', return_value={'sha1': bhash}): actual_hash, actual_search = service.hash_and_search('/some/path') # then self.assertEqual(actual_hash, '456caf10e9535160d90e874b45aa426de762f19f') self.assertFalse(actual_search) self.storage.content_exist.assert_called_with({'sha1': bhash}) @patch('swh.web.ui.service.upload') @istest def test_upload_and_search_upload_OK_basic_case(self, mock_upload): # given (cf. decorators patch) mock_upload.save_in_upload_folder.return_value = ( '/tmp/blah', 'some-filename', None) mock_upload.cleanup.return_value = None file = MagicMock() file.filename = 'some-filename' # when actual_file, actual_hash, actual_search = service.upload_and_search( file) # then self.assertEqual(actual_file, 'some-filename') self.assertIsNone(actual_hash) self.assertIsNone(actual_search) mock_upload.save_in_upload_folder.assert_called_with(file) mock_upload.cleanup.assert_called_with('/tmp/blah') @istest def lookup_origin(self): # given self.storage.origin_get = MagicMock(return_value={ 'id': 'origin-id', 'lister': 'uuid-lister', 'project': 'uuid-project', 'url': 'ftp://some/url/to/origin', 'type': 'ftp'}) # when actual_origin = service.lookup_origin('origin-id') # then self.assertEqual(actual_origin, {'id': 'origin-id', 'lister': 'uuid-lister', 'project': 'uuid-project', 'url': 'ftp://some/url/to/origin', 'type': 'ftp'}) self.storage.origin_get.assert_called_with({'id': 'origin-id'}) @istest def lookup_release(self): - import datetime # given self.storage.release_get = MagicMock(return_value=[{ 'id': hex_to_hash('65a55bbdf3629f916219feb3dcc7393ded1bc8db'), 'revision': None, 'date': datetime.datetime(2015, 1, 1, 22, 0, 0, tzinfo=datetime.timezone.utc), 'date_offset': None, 'name': 'v0.0.1', 'comment': b'synthetic release', 'synthetic': True, }]) # when actual_release = service.lookup_release( '65a55bbdf3629f916219feb3dcc7393ded1bc8db') # then self.assertEqual(actual_release, { 'id': '65a55bbdf3629f916219feb3dcc7393ded1bc8db', 'revision': None, 'date': datetime.datetime(2015, 1, 1, 22, 0, 0, tzinfo=datetime.timezone.utc), 'date_offset': None, 'name': 'v0.0.1', 'comment': 'synthetic release', 'synthetic': True, }) self.storage.release_get.assert_called_with( [hex_to_hash('65a55bbdf3629f916219feb3dcc7393ded1bc8db')]) @istest def lookup_release_ko_id_checksum_not_ok_because_not_a_sha1(self): # given self.storage.release_get = MagicMock() with self.assertRaises(ValueError) as cm: # when service.lookup_release('not-a-sha1') self.assertIn('invalid checksum', cm.exception.args[0]) self.storage.release_get.called = False @istest def lookup_release_ko_id_checksum_ok_but_not_a_sha1(self): # given self.storage.release_get = MagicMock() # when with self.assertRaises(ValueError) as cm: service.lookup_release( '13c1d34d138ec13b5ebad226dc2528dc7506c956e4646f62d4daf5' '1aea892abe') self.assertIn('sha1_git supported', cm.exception.args[0]) self.storage.release_get.called = False + + @istest + def lookup_revision(self): + # given + self.storage.revision_get = MagicMock(return_value=[{ + 'id': hex_to_hash('18d8be353ed3480476f032475e7c233eff7371d5'), + 'directory': hex_to_hash( + '7834ef7e7c357ce2af928115c6c6a42b7e2a44e6'), + 'author_name': b'bill & boule', + 'author_email': b'bill@boule.org', + 'committer_name': b'boule & bill', + 'committer_email': b'boule@bill.org', + 'message': b'elegant fix for bug 31415957', + 'date': datetime.datetime(2000, 1, 17, 11, 23, 54), + 'date_offset': 0, + 'committer_date': datetime.datetime(2000, 1, 17, 11, 23, 54), + 'committer_date_offset': 0, + 'synthetic': False, + 'type': 'git', + 'parents': [], + 'metadata': [], + }]) + + # when + actual_revision = service.lookup_revision( + '18d8be353ed3480476f032475e7c233eff7371d5') + + # then + self.assertEqual(actual_revision, { + 'id': '18d8be353ed3480476f032475e7c233eff7371d5', + 'directory': '7834ef7e7c357ce2af928115c6c6a42b7e2a44e6', + 'author_name': 'bill & boule', + 'author_email': 'bill@boule.org', + 'committer_name': 'boule & bill', + 'committer_email': 'boule@bill.org', + 'message': 'elegant fix for bug 31415957', + 'date': datetime.datetime(2000, 1, 17, 11, 23, 54), + 'date_offset': 0, + 'committer_date': datetime.datetime(2000, 1, 17, 11, 23, 54), + 'committer_date_offset': 0, + 'synthetic': False, + 'type': 'git', + 'parents': [], + 'metadata': [], + }) + + self.storage.revision_get.assert_called_with( + [hex_to_hash('18d8be353ed3480476f032475e7c233eff7371d5')])