API is now pretty much tested.
[wolnelektury.git] / src / api / tests / tests.py
1 # -*- coding: utf-8 -*-
2 # This file is part of Wolnelektury, licensed under GNU Affero GPLv3 or later.
3 # Copyright © Fundacja Nowoczesna Polska. See NOTICE for more information.
4 #
5 from base64 import b64encode
6 from os import path
7 import hashlib
8 import hmac
9 import json
10 from StringIO import StringIO
11 from time import time
12 from urllib import quote, urlencode
13 from urlparse import parse_qs
14
15 from django.contrib.auth.models import User
16 from django.core.files.uploadedfile import SimpleUploadedFile
17 from django.test import TestCase
18 from django.test.utils import override_settings
19 from mock import patch
20 from piston.models import Consumer, Token
21
22 from catalogue.models import Book, Tag
23 from picture.forms import PictureImportForm
24 from picture.models import Picture
25 import picture.tests
26
27
28 @override_settings(
29     NO_SEARCH_INDEX=True,
30     CACHES={'default': {
31         'BACKEND': 'django.core.cache.backends.dummy.DummyCache'}},
32 )
33 class ApiTest(TestCase):
34     def load_json(self, url):
35         content = self.client.get(url).content
36         try:
37             data = json.loads(content)
38         except ValueError:
39             self.fail('No JSON could be decoded: %s' % content)
40         return data
41
42     def assert_json_response(self, url, name):
43         data = self.load_json(url)
44         filename = path.join(path.dirname(__file__), 'res', 'responses', name)
45         with open(filename) as f:
46             good_data = json.load(f)
47         self.assertEqual(data, good_data, json.dumps(data, indent=4))
48
49     def assert_slugs(self, url, slugs):
50         have_slugs = [x['slug'] for x in self.load_json(url)]
51         self.assertEqual(have_slugs, slugs, have_slugs)
52
53
54 class BookTests(ApiTest):
55
56     def setUp(self):
57         self.tag = Tag.objects.create(category='author', slug='joe')
58         self.book = Book.objects.create(title='A Book', slug='a-book')
59         self.book_tagged = Book.objects.create(
60             title='Tagged Book', slug='tagged-book')
61         self.book_tagged.tags = [self.tag]
62         self.book_tagged.save()
63
64     def test_book_list(self):
65         books = self.load_json('/api/books/')
66         self.assertEqual(len(books), 2,
67                          'Wrong book list.')
68
69     def test_tagged_books(self):
70         books = self.load_json('/api/authors/joe/books/')
71
72         self.assertEqual([b['title'] for b in books], [self.book_tagged.title],
73                          'Wrong tagged book list.')
74
75     def test_detail(self):
76         book = self.load_json('/api/books/a-book/')
77         self.assertEqual(book['title'], self.book.title,
78                          'Wrong book details.')
79
80
81 class TagTests(ApiTest):
82
83     def setUp(self):
84         self.tag = Tag.objects.create(
85             category='author', slug='joe', name='Joe')
86         self.book = Book.objects.create(title='A Book', slug='a-book')
87         self.book.tags = [self.tag]
88         self.book.save()
89
90     def test_tag_list(self):
91         tags = self.load_json('/api/authors/')
92         self.assertEqual(len(tags), 1,
93                          'Wrong tag list.')
94
95     def test_tag_detail(self):
96         tag = self.load_json('/api/authors/joe/')
97         self.assertEqual(tag['name'], self.tag.name,
98                          'Wrong tag details.')
99
100
101 class PictureTests(ApiTest):
102     def test_publish(self):
103         slug = "kandinsky-composition-viii"
104         xml = SimpleUploadedFile(
105             'composition8.xml',
106             open(path.join(
107                 picture.tests.__path__[0], "files", slug + ".xml"
108             )).read())
109         img = SimpleUploadedFile(
110             'kompozycja-8.png',
111             open(path.join(
112                 picture.tests.__path__[0], "files", slug + ".png"
113             )).read())
114
115         import_form = PictureImportForm({}, {
116             'picture_xml_file': xml,
117             'picture_image_file': img
118             })
119
120         assert import_form.is_valid()
121         if import_form.is_valid():
122             import_form.save()
123
124         Picture.objects.get(slug=slug)
125
126
127 class BooksTests(ApiTest):
128     fixtures = ['test-books.yaml']
129
130     def test_books(self):
131         self.assert_json_response('/api/books/', 'books.json')
132         self.assert_json_response('/api/books/?new_api=true', 'books.json')
133
134         self.assert_slugs('/api/audiobooks/', ['parent'])
135         self.assert_slugs('/api/daisy/', ['parent'])
136         self.assert_slugs('/api/newest/', ['parent'])
137         self.assert_slugs('/api/parent_books/', ['parent'])
138         self.assert_slugs('/api/recommended/', ['parent'])
139
140         # Book paging.
141         self.assert_slugs('/api/books/after/grandchild/count/1/', ['parent'])
142         self.assert_slugs(
143             '/api/books/?new_api=true&after=$grandchild$3&count=1', ['parent'])
144
145         # By tag.
146         self.assert_slugs('/api/authors/john-doe/books/', ['parent'])
147         self.assert_slugs(
148             '/api/genres/sonet/books/?authors=john-doe',
149             ['parent'])
150         # It is probably a mistake that this doesn't filter:
151         self.assert_slugs(
152             '/api/books/?authors=john-doe',
153             ['child', 'grandchild', 'parent'])
154
155         # Parent books by tag.
156         # Notice this contains a grandchild, if a child doesn't have the tag.
157         # This probably isn't really intended behavior and should be redefined.
158         self.assert_slugs(
159             '/api/genres/sonet/parent_books/',
160             ['grandchild', 'parent'])
161
162     def test_ebooks(self):
163         self.assert_json_response('/api/ebooks/', 'ebooks.json')
164
165     def test_filter_books(self):
166         self.assert_json_response('/api/filter-books/', 'filter-books.json')
167         self.assert_slugs(
168             '/api/filter-books/?lektura=false',
169             ['child', 'grandchild', 'parent'])
170         self.assert_slugs(
171             '/api/filter-books/?lektura=true',
172             [])
173
174         self.assert_slugs(
175             '/api/filter-books/?preview=true',
176             ['grandchild'])
177         self.assert_slugs(
178             '/api/filter-books/?preview=false',
179             ['child', 'parent'])
180
181         self.assert_slugs(
182             '/api/filter-books/?audiobook=true',
183             ['parent'])
184         self.assert_slugs(
185             '/api/filter-books/?audiobook=false',
186             ['child', 'grandchild'])
187
188         self.assert_slugs('/api/filter-books/?genres=wiersz', ['child'])
189
190         self.assert_slugs('/api/filter-books/?search=parent', ['parent'])
191
192     def test_collections(self):
193         self.assert_json_response('/api/collections/', 'collections.json')
194         self.assert_json_response(
195             '/api/collections/a-collection/',
196             'collection.json')
197
198     def test_book(self):
199         self.assert_json_response('/api/books/parent/', 'books-parent.json')
200         self.assert_json_response('/api/books/child/', 'books-child.json')
201         self.assert_json_response(
202             '/api/books/grandchild/',
203             'books-grandchild.json')
204
205     def test_tags(self):
206         # List of tags by category.
207         self.assert_json_response('/api/genres/', 'tags.json')
208
209     def test_fragments(self):
210         # This is not supported, though it probably should be.
211         # self.assert_json_response(
212         #     '/api/books/child/fragments/',
213         #     'fragments.json')
214
215         self.assert_json_response(
216             '/api/genres/wiersz/fragments/',
217             'fragments.json')
218         self.assert_json_response(
219             '/api/books/child/fragments/an-anchor/',
220             'fragment.json')
221
222
223 class BlogTests(ApiTest):
224     def test_get(self):
225         self.assertEqual(self.load_json('/api/blog/'), [])
226
227
228 class PreviewTests(ApiTest):
229     def unauth(self):
230         self.assert_json_response('/api/preview/', 'preview.json')
231
232
233 class OAuth1Tests(ApiTest):
234     @classmethod
235     def setUpClass(cls):
236         cls.user = User.objects.create(username='test')
237         cls.consumer_secret = 'len(quote(consumer secret))>=32'
238         Consumer.objects.create(
239             key='client',
240             secret=cls.consumer_secret
241         )
242
243     @classmethod
244     def tearDownClass(cls):
245         User.objects.all().delete()
246
247     def test_create_token(self):
248         base_query = ("oauth_consumer_key=client&oauth_nonce=123&"
249                       "oauth_signature_method=HMAC-SHA1&oauth_timestamp={}&"
250                       "oauth_version=1.0".format(int(time())))
251         raw = '&'.join([
252             'GET',
253             quote('http://testserver/api/oauth/request_token/', safe=''),
254             quote(base_query, safe='')
255         ])
256         h = hmac.new(
257             quote(self.consumer_secret) + '&', raw, hashlib.sha1
258         ).digest()
259         h = b64encode(h).rstrip('\n')
260         sign = quote(h)
261         query = "{}&oauth_signature={}".format(base_query, sign)
262         response = self.client.get('/api/oauth/request_token/?' + query)
263         request_token = parse_qs(response.content)
264
265         Token.objects.filter(
266             key=request_token['oauth_token'][0], token_type=Token.REQUEST
267         ).update(user=self.user, is_approved=True)
268
269         base_query = ("oauth_consumer_key=client&oauth_nonce=123&"
270                       "oauth_signature_method=HMAC-SHA1&oauth_timestamp={}&"
271                       "oauth_token={}&oauth_version=1.0".format(
272                           int(time()), request_token['oauth_token'][0]))
273         raw = '&'.join([
274             'GET',
275             quote('http://testserver/api/oauth/access_token/', safe=''),
276             quote(base_query, safe='')
277         ])
278         h = hmac.new(
279             quote(self.consumer_secret) + '&' +
280             quote(request_token['oauth_token_secret'][0], safe=''),
281             raw,
282             hashlib.sha1
283         ).digest()
284         h = b64encode(h).rstrip('\n')
285         sign = quote(h)
286         query = u"{}&oauth_signature={}".format(base_query, sign)
287         response = self.client.get(u'/api/oauth/access_token/?' + query)
288         access_token = parse_qs(response.content)
289
290         self.assertTrue(
291             Token.objects.filter(
292                 key=access_token['oauth_token'][0],
293                 token_type=Token.ACCESS,
294                 user=self.user
295             ).exists())
296
297
298 class AuthorizedTests(ApiTest):
299     fixtures = ['test-books.yaml']
300
301     @classmethod
302     def setUpClass(cls):
303         super(AuthorizedTests, cls).setUpClass()
304         cls.user = User.objects.create(username='test')
305         cls.consumer = Consumer.objects.create(
306             key='client', secret='12345678901234567890123456789012')
307         cls.token = Token.objects.create(
308             key='123456789012345678',
309             secret='12345678901234567890123456789012',
310             user=cls.user,
311             consumer=cls.consumer,
312             token_type=Token.ACCESS,
313             timestamp=time())
314         cls.key = cls.consumer.secret + '&' + cls.token.secret
315
316     @classmethod
317     def tearDownClass(cls):
318         cls.user.delete()
319         cls.consumer.delete()
320         super(AuthorizedTests, cls).tearDownClass()
321
322     def signed(self, url, method='GET', params=None):
323         auth_params = {
324             "oauth_consumer_key": self.consumer.key,
325             "oauth_nonce": "%f" % time(),
326             "oauth_signature_method": "HMAC-SHA1",
327             "oauth_timestamp": int(time()),
328             "oauth_token": self.token.key,
329             "oauth_version": "1.0",
330         }
331
332         sign_params = {}
333         if params:
334             sign_params.update(params)
335         sign_params.update(auth_params)
336         raw = "&".join([
337             method.upper(),
338             quote('http://testserver' + url, safe=''),
339             quote("&".join(
340                 quote(str(k)) + "=" + quote(str(v))
341                 for (k, v) in sorted(sign_params.items())))
342         ])
343         auth_params["oauth_signature"] = quote(b64encode(hmac.new(
344             self.key, raw, hashlib.sha1).digest()).rstrip('\n'))
345         auth = 'OAuth realm="API", ' + ', '.join(
346             '{}="{}"'.format(k, v) for (k, v) in auth_params.items())
347
348         if params:
349             url = url + '?' + urlencode(params)
350         return getattr(self.client, method.lower())(
351                 url,
352                 HTTP_AUTHORIZATION=auth
353             )
354
355     def signed_json(self, url, method='GET', params=None):
356         return json.loads(self.signed(url, method, params).content)
357
358     def test_books(self):
359         self.assertEqual(
360             self.signed_json('/api/like/parent/'),
361             {"likes": False}
362         )
363         self.signed('/api/like/parent/', 'POST')
364         self.assertEqual(
365             self.signed_json('/api/like/parent/'),
366             {"likes": True}
367         )
368         # There are several endpoints where 'liked' appears.
369         self.assertTrue(self.signed_json('/api/parent_books/')[0]['liked'])
370         self.assertTrue(self.signed_json(
371             '/api/filter-books/', params={"search": "parent"})[0]['liked'])
372         # Liked books go on shelf.
373         self.assertEqual(
374             [x['slug'] for x in self.signed_json('/api/shelf/likes/')],
375             ['parent'])
376
377         self.signed('/api/like/parent/', 'POST', {"action": "unlike"})
378         self.assertEqual(
379             self.signed_json('/api/like/parent/'),
380             {"likes": False}
381         )
382         self.assertFalse(self.signed_json('/api/parent_books/')[0]['liked'])
383
384     def test_reading(self):
385         self.assertEqual(
386             self.signed_json('/api/reading/parent/'),
387             {"state": "not_started"}
388         )
389         self.signed('/api/reading/parent/reading/', 'post')
390         self.assertEqual(
391             self.signed_json('/api/reading/parent/'),
392             {"state": "reading"}
393         )
394         self.assertEqual(
395             [x['slug'] for x in self.signed_json('/api/shelf/reading/')],
396             ['parent'])
397
398     def test_subscription(self):
399         self.assert_slugs('/api/preview/', ['grandchild'])
400         self.assertEqual(
401             self.signed_json('/api/username/'),
402             {"username": "test", "premium": False})
403         self.assertEqual(
404             self.signed('/api/epub/grandchild/').status_code,
405             401)  # Not 403 because Piston.
406
407         with patch('api.handlers.user_is_subscribed', return_value=True):
408             self.assertEqual(
409                 self.signed_json('/api/username/'),
410                 {"username": "test", "premium": True})
411             with patch('django.core.files.storage.Storage.open',
412                        return_value=StringIO("<epub>")):
413                 self.assertEqual(
414                     self.signed('/api/epub/grandchild/').content,
415                     "<epub>")