import string import uuid from django.core.exceptions import ImproperlyConfigured from django.test import SimpleTestCase from django.test.utils import override_settings from django.urls import NoReverseMatch, Resolver404, path, resolve, reverse from django.views import View from .converters import DynamicConverter from .views import empty_view included_kwargs = {'base': b'hello', 'value': b'world'} converter_test_data = ( # ('url', ('url_name', 'app_name', {kwargs})), # aGVsbG8= is 'hello' encoded in base64. ('/base64/aGVsbG8=/', ('base64', '', {'value': b'hello'})), ('/base64/aGVsbG8=/subpatterns/d29ybGQ=/', ('subpattern-base64', '', included_kwargs)), ('/base64/aGVsbG8=/namespaced/d29ybGQ=/', ('subpattern-base64', 'namespaced-base64', included_kwargs)), ) @override_settings(ROOT_URLCONF='urlpatterns.path_urls') class SimplifiedURLTests(SimpleTestCase): def test_path_lookup_without_parameters(self): match = resolve('/articles/2003/') self.assertEqual(match.url_name, 'articles-2003') self.assertEqual(match.args, ()) self.assertEqual(match.kwargs, {}) self.assertEqual(match.route, 'articles/2003/') def test_path_lookup_with_typed_parameters(self): match = resolve('/articles/2015/') self.assertEqual(match.url_name, 'articles-year') self.assertEqual(match.args, ()) self.assertEqual(match.kwargs, {'year': 2015}) self.assertEqual(match.route, 'articles//') def test_path_lookup_with_multiple_parameters(self): match = resolve('/articles/2015/04/12/') self.assertEqual(match.url_name, 'articles-year-month-day') self.assertEqual(match.args, ()) self.assertEqual(match.kwargs, {'year': 2015, 'month': 4, 'day': 12}) self.assertEqual(match.route, 'articles////') def test_two_variable_at_start_of_path_pattern(self): match = resolve('/en/foo/') self.assertEqual(match.url_name, 'lang-and-path') self.assertEqual(match.kwargs, {'lang': 'en', 'url': 'foo'}) self.assertEqual(match.route, '//') def test_re_path(self): match = resolve('/regex/1/') self.assertEqual(match.url_name, 'regex') self.assertEqual(match.kwargs, {'pk': '1'}) self.assertEqual(match.route, '^regex/(?P[0-9]+)/$') def test_re_path_with_optional_parameter(self): for url, kwargs in ( ('/regex_optional/1/2/', {'arg1': '1', 'arg2': '2'}), ('/regex_optional/1/', {'arg1': '1'}), ): with self.subTest(url=url): match = resolve(url) self.assertEqual(match.url_name, 'regex_optional') self.assertEqual(match.kwargs, kwargs) self.assertEqual( match.route, r'^regex_optional/(?P\d+)/(?:(?P\d+)/)?', ) def test_re_path_with_missing_optional_parameter(self): match = resolve('/regex_only_optional/') self.assertEqual(match.url_name, 'regex_only_optional') self.assertEqual(match.kwargs, {}) self.assertEqual(match.args, ()) self.assertEqual( match.route, r'^regex_only_optional/(?:(?P\d+)/)?', ) def test_path_lookup_with_inclusion(self): match = resolve('/included_urls/extra/something/') self.assertEqual(match.url_name, 'inner-extra') self.assertEqual(match.route, 'included_urls/extra//') def test_path_lookup_with_empty_string_inclusion(self): match = resolve('/more/99/') self.assertEqual(match.url_name, 'inner-more') self.assertEqual(match.route, r'^more/(?P\w+)/$') def test_path_lookup_with_double_inclusion(self): match = resolve('/included_urls/more/some_value/') self.assertEqual(match.url_name, 'inner-more') self.assertEqual(match.route, r'included_urls/more/(?P\w+)/$') def test_path_reverse_without_parameter(self): url = reverse('articles-2003') self.assertEqual(url, '/articles/2003/') def test_path_reverse_with_parameter(self): url = reverse('articles-year-month-day', kwargs={'year': 2015, 'month': 4, 'day': 12}) self.assertEqual(url, '/articles/2015/4/12/') @override_settings(ROOT_URLCONF='urlpatterns.path_base64_urls') def test_converter_resolve(self): for url, (url_name, app_name, kwargs) in converter_test_data: with self.subTest(url=url): match = resolve(url) self.assertEqual(match.url_name, url_name) self.assertEqual(match.app_name, app_name) self.assertEqual(match.kwargs, kwargs) @override_settings(ROOT_URLCONF='urlpatterns.path_base64_urls') def test_converter_reverse(self): for expected, (url_name, app_name, kwargs) in converter_test_data: if app_name: url_name = '%s:%s' % (app_name, url_name) with self.subTest(url=url_name): url = reverse(url_name, kwargs=kwargs) self.assertEqual(url, expected) @override_settings(ROOT_URLCONF='urlpatterns.path_base64_urls') def test_converter_reverse_with_second_layer_instance_namespace(self): kwargs = included_kwargs.copy() kwargs['last_value'] = b'world' url = reverse('instance-ns-base64:subsubpattern-base64', kwargs=kwargs) self.assertEqual(url, '/base64/aGVsbG8=/subpatterns/d29ybGQ=/d29ybGQ=/') def test_path_inclusion_is_matchable(self): match = resolve('/included_urls/extra/something/') self.assertEqual(match.url_name, 'inner-extra') self.assertEqual(match.kwargs, {'extra': 'something'}) def test_path_inclusion_is_reversible(self): url = reverse('inner-extra', kwargs={'extra': 'something'}) self.assertEqual(url, '/included_urls/extra/something/') def test_invalid_converter(self): msg = "URL route 'foo//' uses invalid converter 'nonexistent'." with self.assertRaisesMessage(ImproperlyConfigured, msg): path('foo//', empty_view) def test_invalid_view(self): msg = 'view must be a callable or a list/tuple in the case of include().' with self.assertRaisesMessage(TypeError, msg): path('articles/', 'invalid_view') def test_invalid_view_instance(self): class EmptyCBV(View): pass msg = 'view must be a callable, pass EmptyCBV.as_view(), not EmptyCBV().' with self.assertRaisesMessage(TypeError, msg): path('foo', EmptyCBV()) def test_whitespace_in_route(self): msg = ( "URL route 'space//extra/' cannot contain " "whitespace in angle brackets <…>" ) for whitespace in string.whitespace: with self.subTest(repr(whitespace)): with self.assertRaisesMessage(ImproperlyConfigured, msg % whitespace): path('space//extra/' % whitespace, empty_view) # Whitespaces are valid in paths. p = path('space%s//' % string.whitespace, empty_view) match = p.resolve('space%s/1/' % string.whitespace) self.assertEqual(match.kwargs, {'num': 1}) def test_path_trailing_newlines(self): tests = [ '/articles/2003/\n', '/articles/2010/\n', '/en/foo/\n', '/included_urls/extra/\n', '/regex/1/\n', '/users/1/\n', ] for url in tests: with self.subTest(url=url), self.assertRaises(Resolver404): resolve(url) @override_settings(ROOT_URLCONF='urlpatterns.converter_urls') class ConverterTests(SimpleTestCase): def test_matching_urls(self): def no_converter(x): return x test_data = ( ('int', {'0', '1', '01', 1234567890}, int), ('str', {'abcxyz'}, no_converter), ('path', {'allows.ANY*characters'}, no_converter), ('slug', {'abcxyz-ABCXYZ_01234567890'}, no_converter), ('uuid', {'39da9369-838e-4750-91a5-f7805cd82839'}, uuid.UUID), ) for url_name, url_suffixes, converter in test_data: for url_suffix in url_suffixes: url = '/%s/%s/' % (url_name, url_suffix) with self.subTest(url=url): match = resolve(url) self.assertEqual(match.url_name, url_name) self.assertEqual(match.kwargs, {url_name: converter(url_suffix)}) # reverse() works with string parameters. string_kwargs = {url_name: url_suffix} self.assertEqual(reverse(url_name, kwargs=string_kwargs), url) # reverse() also works with native types (int, UUID, etc.). if converter is not no_converter: # The converted value might be different for int (a # leading zero is lost in the conversion). converted_value = match.kwargs[url_name] converted_url = '/%s/%s/' % (url_name, converted_value) self.assertEqual(reverse(url_name, kwargs={url_name: converted_value}), converted_url) def test_nonmatching_urls(self): test_data = ( ('int', {'-1', 'letters'}), ('str', {'', '/'}), ('path', {''}), ('slug', {'', 'stars*notallowed'}), ('uuid', { '', '9da9369-838e-4750-91a5-f7805cd82839', '39da9369-838-4750-91a5-f7805cd82839', '39da9369-838e-475-91a5-f7805cd82839', '39da9369-838e-4750-91a-f7805cd82839', '39da9369-838e-4750-91a5-f7805cd8283', }), ) for url_name, url_suffixes in test_data: for url_suffix in url_suffixes: url = '/%s/%s/' % (url_name, url_suffix) with self.subTest(url=url), self.assertRaises(Resolver404): resolve(url) @override_settings(ROOT_URLCONF='urlpatterns.path_same_name_urls') class SameNameTests(SimpleTestCase): def test_matching_urls_same_name(self): @DynamicConverter.register_to_url def requires_tiny_int(value): if value > 5: raise ValueError return value tests = [ ('number_of_args', [ ([], {}, '0/'), ([1], {}, '1/1/'), ]), ('kwargs_names', [ ([], {'a': 1}, 'a/1/'), ([], {'b': 1}, 'b/1/'), ]), ('converter', [ (['a/b'], {}, 'path/a/b/'), (['a b'], {}, 'str/a%20b/'), (['a-b'], {}, 'slug/a-b/'), (['2'], {}, 'int/2/'), ( ['39da9369-838e-4750-91a5-f7805cd82839'], {}, 'uuid/39da9369-838e-4750-91a5-f7805cd82839/' ), ]), ('regex', [ (['ABC'], {}, 'uppercase/ABC/'), (['abc'], {}, 'lowercase/abc/'), ]), ('converter_to_url', [ ([6], {}, 'int/6/'), ([1], {}, 'tiny_int/1/'), ]), ] for url_name, cases in tests: for args, kwargs, url_suffix in cases: expected_url = '/%s/%s' % (url_name, url_suffix) with self.subTest(url=expected_url): self.assertEqual( reverse(url_name, args=args, kwargs=kwargs), expected_url, ) class ParameterRestrictionTests(SimpleTestCase): def test_integer_parameter_name_causes_exception(self): msg = ( "URL route 'hello//' uses parameter name '1' which isn't " "a valid Python identifier." ) with self.assertRaisesMessage(ImproperlyConfigured, msg): path(r'hello//', lambda r: None) def test_non_identifier_parameter_name_causes_exception(self): msg = ( "URL route 'b//' uses parameter name 'book.id' which " "isn't a valid Python identifier." ) with self.assertRaisesMessage(ImproperlyConfigured, msg): path(r'b//', lambda r: None) def test_allows_non_ascii_but_valid_identifiers(self): # \u0394 is "GREEK CAPITAL LETTER DELTA", a valid identifier. p = path('hello//', lambda r: None) match = p.resolve('hello/1/') self.assertEqual(match.kwargs, {'\u0394': '1'}) @override_settings(ROOT_URLCONF='urlpatterns.path_dynamic_urls') class ConversionExceptionTests(SimpleTestCase): """How are errors in Converter.to_python() and to_url() handled?""" def test_resolve_value_error_means_no_match(self): @DynamicConverter.register_to_python def raises_value_error(value): raise ValueError() with self.assertRaises(Resolver404): resolve('/dynamic/abc/') def test_resolve_type_error_propagates(self): @DynamicConverter.register_to_python def raises_type_error(value): raise TypeError('This type error propagates.') with self.assertRaisesMessage(TypeError, 'This type error propagates.'): resolve('/dynamic/abc/') def test_reverse_value_error_means_no_match(self): @DynamicConverter.register_to_url def raises_value_error(value): raise ValueError with self.assertRaises(NoReverseMatch): reverse('dynamic', kwargs={'value': object()}) def test_reverse_type_error_propagates(self): @DynamicConverter.register_to_url def raises_type_error(value): raise TypeError('This type error propagates.') with self.assertRaisesMessage(TypeError, 'This type error propagates.'): reverse('dynamic', kwargs={'value': object()})