22 __docformat__ = "restructuredtext en" |
22 __docformat__ = "restructuredtext en" |
23 |
23 |
24 import os |
24 import os |
25 import sys |
25 import sys |
26 import re |
26 import re |
|
27 import urlparse |
|
28 from os.path import dirname, join |
27 from urllib import unquote |
29 from urllib import unquote |
28 from math import log |
30 from math import log |
29 from contextlib import contextmanager |
31 from contextlib import contextmanager |
30 from warnings import warn |
32 from warnings import warn |
31 |
33 |
32 import yams.schema |
34 import yams.schema |
33 |
35 |
34 from logilab.common.testlib import TestCase, InnerTest |
36 from logilab.common.testlib import TestCase, InnerTest, Tags |
35 from logilab.common.pytest import nocoverage, pause_tracing, resume_tracing |
37 from logilab.common.pytest import nocoverage, pause_tracing, resume_tracing |
36 from logilab.common.debugger import Debugger |
38 from logilab.common.debugger import Debugger |
37 from logilab.common.umessage import message_from_string |
39 from logilab.common.umessage import message_from_string |
38 from logilab.common.decorators import cached, classproperty, clear_cache |
40 from logilab.common.decorators import cached, classproperty, clear_cache |
39 from logilab.common.deprecation import deprecated |
41 from logilab.common.deprecation import deprecated |
42 from cubicweb import cwconfig, devtools, web, server |
44 from cubicweb import cwconfig, devtools, web, server |
43 from cubicweb.dbapi import ProgrammingError, DBAPISession, repo_connect |
45 from cubicweb.dbapi import ProgrammingError, DBAPISession, repo_connect |
44 from cubicweb.sobjects import notification |
46 from cubicweb.sobjects import notification |
45 from cubicweb.web import Redirect, application |
47 from cubicweb.web import Redirect, application |
46 from cubicweb.server.session import security_enabled |
48 from cubicweb.server.session import security_enabled |
|
49 from cubicweb.server.hook import SendMailOp |
47 from cubicweb.devtools import SYSTEM_ENTITIES, SYSTEM_RELATIONS, VIEW_VALIDATORS |
50 from cubicweb.devtools import SYSTEM_ENTITIES, SYSTEM_RELATIONS, VIEW_VALIDATORS |
48 from cubicweb.devtools import fake, htmlparser |
51 from cubicweb.devtools import BASE_URL, fake, htmlparser |
49 from cubicweb.utils import json |
52 from cubicweb.utils import json |
50 |
53 |
51 # low-level utilities ########################################################## |
54 # low-level utilities ########################################################## |
52 |
55 |
53 class CubicWebDebugger(Debugger): |
56 class CubicWebDebugger(Debugger): |
67 """ |
70 """ |
68 if after is None: |
71 if after is None: |
69 after = before |
72 after = before |
70 return center - before <= line_no <= center + after |
73 return center - before <= line_no <= center + after |
71 |
74 |
72 |
|
73 def unprotected_entities(schema, strict=False): |
75 def unprotected_entities(schema, strict=False): |
74 """returned a set of each non final entity type, excluding "system" entities |
76 """returned a set of each non final entity type, excluding "system" entities |
75 (eg CWGroup, CWUser...) |
77 (eg CWGroup, CWUser...) |
76 """ |
78 """ |
77 if strict: |
79 if strict: |
78 protected_entities = yams.schema.BASE_TYPES |
80 protected_entities = yams.schema.BASE_TYPES |
79 else: |
81 else: |
80 protected_entities = yams.schema.BASE_TYPES.union(SYSTEM_ENTITIES) |
82 protected_entities = yams.schema.BASE_TYPES.union(SYSTEM_ENTITIES) |
81 return set(schema.entities()) - protected_entities |
83 return set(schema.entities()) - protected_entities |
82 |
|
83 |
84 |
84 def refresh_repo(repo, resetschema=False, resetvreg=False): |
85 def refresh_repo(repo, resetschema=False, resetvreg=False): |
85 for pool in repo.pools: |
86 for pool in repo.pools: |
86 pool.close(True) |
87 pool.close(True) |
87 repo.system_source.shutdown() |
88 repo.system_source.shutdown() |
141 MAILBOX.append(Email(recipients, msg)) |
142 MAILBOX.append(Email(recipients, msg)) |
142 |
143 |
143 cwconfig.SMTP = MockSMTP |
144 cwconfig.SMTP = MockSMTP |
144 |
145 |
145 |
146 |
|
147 class TestCaseConnectionProxy(object): |
|
148 """thin wrapper around `cubicweb.dbapi.Connection` context-manager |
|
149 used in CubicWebTC (cf. `cubicweb.devtools.testlib.CubicWebTC.login` method) |
|
150 |
|
151 It just proxies to the default connection context manager but |
|
152 restores the original connection on exit. |
|
153 """ |
|
154 def __init__(self, testcase, cnx): |
|
155 self.testcase = testcase |
|
156 self.cnx = cnx |
|
157 |
|
158 def __getattr__(self, attrname): |
|
159 return getattr(self.cnx, attrname) |
|
160 |
|
161 def __enter__(self): |
|
162 return self.cnx.__enter__() |
|
163 |
|
164 def __exit__(self, exctype, exc, tb): |
|
165 try: |
|
166 return self.cnx.__exit__(exctype, exc, tb) |
|
167 finally: |
|
168 self.cnx.close() |
|
169 self.testcase.restore_connection() |
|
170 |
146 # base class for cubicweb tests requiring a full cw environments ############### |
171 # base class for cubicweb tests requiring a full cw environments ############### |
147 |
172 |
148 class CubicWebTC(TestCase): |
173 class CubicWebTC(TestCase): |
149 """abstract class for test using an apptest environment |
174 """abstract class for test using an apptest environment |
150 |
175 |
161 * `admpassword`, password of the admin user |
186 * `admpassword`, password of the admin user |
162 """ |
187 """ |
163 appid = 'data' |
188 appid = 'data' |
164 configcls = devtools.ApptestConfiguration |
189 configcls = devtools.ApptestConfiguration |
165 reset_schema = reset_vreg = False # reset schema / vreg between tests |
190 reset_schema = reset_vreg = False # reset schema / vreg between tests |
|
191 tags = TestCase.tags | Tags('cubicweb', 'cw_repo') |
166 |
192 |
167 @classproperty |
193 @classproperty |
168 def config(cls): |
194 def config(cls): |
169 """return the configuration object. Configuration is cached on the test |
195 """return the configuration object |
170 class. |
196 |
|
197 Configuration is cached on the test class. |
171 """ |
198 """ |
172 try: |
199 try: |
173 return cls.__dict__['_config'] |
200 return cls.__dict__['_config'] |
174 except KeyError: |
201 except KeyError: |
175 config = cls._config = cls.configcls(cls.appid) |
202 home = join(dirname(sys.modules[cls.__module__].__file__), cls.appid) |
|
203 config = cls._config = cls.configcls(cls.appid, apphome=home) |
176 config.mode = 'test' |
204 config.mode = 'test' |
177 return config |
205 return config |
178 |
206 |
179 @classmethod |
207 @classmethod |
180 def init_config(cls, config): |
208 def init_config(cls, config): |
181 """configuration initialization hooks. You may want to override this.""" |
209 """configuration initialization hooks. |
|
210 |
|
211 You may only want to override here the configuraton logic. |
|
212 |
|
213 Otherwise, consider to use a different :class:`ApptestConfiguration` |
|
214 defined in the `configcls` class attribute""" |
182 source = config.sources()['system'] |
215 source = config.sources()['system'] |
183 cls.admlogin = unicode(source['db-user']) |
216 cls.admlogin = unicode(source['db-user']) |
184 cls.admpassword = source['db-password'] |
217 cls.admpassword = source['db-password'] |
185 # uncomment the line below if you want rql queries to be logged |
218 # uncomment the line below if you want rql queries to be logged |
186 #config.global_set_option('query-log-file', |
219 #config.global_set_option('query-log-file', |
198 or os.environ.get('LOGNAME')) |
231 or os.environ.get('LOGNAME')) |
199 config.global_set_option('sender-addr', send_to) |
232 config.global_set_option('sender-addr', send_to) |
200 config.global_set_option('default-dest-addrs', send_to) |
233 config.global_set_option('default-dest-addrs', send_to) |
201 config.global_set_option('sender-name', 'cubicweb-test') |
234 config.global_set_option('sender-name', 'cubicweb-test') |
202 config.global_set_option('sender-addr', 'cubicweb-test@logilab.fr') |
235 config.global_set_option('sender-addr', 'cubicweb-test@logilab.fr') |
|
236 # default_base_url on config class isn't enough for TestServerConfiguration |
|
237 config.global_set_option('base-url', config.default_base_url()) |
203 # web resources |
238 # web resources |
204 config.global_set_option('base-url', devtools.BASE_URL) |
|
205 try: |
239 try: |
206 config.global_set_option('embed-allowed', re.compile('.*')) |
240 config.global_set_option('embed-allowed', re.compile('.*')) |
207 except: # not in server only configuration |
241 except: # not in server only configuration |
208 pass |
242 pass |
209 |
243 |
264 server.set_debug(debugmode) |
298 server.set_debug(debugmode) |
265 |
299 |
266 # default test setup and teardown ######################################### |
300 # default test setup and teardown ######################################### |
267 |
301 |
268 def setUp(self): |
302 def setUp(self): |
|
303 # monkey patch send mail operation so emails are sent synchronously |
|
304 self._old_mail_commit_event = SendMailOp.commit_event |
|
305 SendMailOp.commit_event = SendMailOp.sendmails |
269 pause_tracing() |
306 pause_tracing() |
270 previous_failure = self.__class__.__dict__.get('_repo_init_failed') |
307 previous_failure = self.__class__.__dict__.get('_repo_init_failed') |
271 if previous_failure is not None: |
308 if previous_failure is not None: |
272 self.skip('repository is not initialised: %r' % previous_failure) |
309 self.skipTest('repository is not initialised: %r' % previous_failure) |
273 try: |
310 try: |
274 self._init_repo() |
311 self._init_repo() |
275 except Exception, ex: |
312 except Exception, ex: |
276 self.__class__._repo_init_failed = ex |
313 self.__class__._repo_init_failed = ex |
277 raise |
314 raise |
285 if not self.cnx._closed: |
322 if not self.cnx._closed: |
286 self.cnx.rollback() |
323 self.cnx.rollback() |
287 for cnx in self._cnxs: |
324 for cnx in self._cnxs: |
288 if not cnx._closed: |
325 if not cnx._closed: |
289 cnx.close() |
326 cnx.close() |
|
327 SendMailOp.commit_event = self._old_mail_commit_event |
290 |
328 |
291 def setup_database(self): |
329 def setup_database(self): |
292 """add your database setup code by overriding this method""" |
330 """add your database setup code by overriding this method""" |
293 |
331 |
294 # user / session management ############################################### |
332 # user / session management ############################################### |
311 user = req.create_entity('CWUser', login=unicode(login), |
349 user = req.create_entity('CWUser', login=unicode(login), |
312 upassword=password, **kwargs) |
350 upassword=password, **kwargs) |
313 req.execute('SET X in_group G WHERE X eid %%(x)s, G name IN(%s)' |
351 req.execute('SET X in_group G WHERE X eid %%(x)s, G name IN(%s)' |
314 % ','.join(repr(g) for g in groups), |
352 % ','.join(repr(g) for g in groups), |
315 {'x': user.eid}) |
353 {'x': user.eid}) |
316 user.clear_related_cache('in_group', 'subject') |
354 user.cw_clear_relation_cache('in_group', 'subject') |
317 if commit: |
355 if commit: |
318 req.cnx.commit() |
356 req.cnx.commit() |
319 return user |
357 return user |
320 |
358 |
321 def login(self, login, **kwargs): |
359 def login(self, login, **kwargs): |
322 """return a connection for the given login/password""" |
360 """return a connection for the given login/password""" |
323 if login == self.admlogin: |
361 if login == self.admlogin: |
324 self.restore_connection() |
362 self.restore_connection() |
325 else: |
363 # definitly don't want autoclose when used as a context manager |
326 if not kwargs: |
364 return self.cnx |
327 kwargs['password'] = str(login) |
365 autoclose = kwargs.pop('autoclose', True) |
328 self.cnx = repo_connect(self.repo, unicode(login), **kwargs) |
366 if not kwargs: |
329 self.websession = DBAPISession(self.cnx) |
367 kwargs['password'] = str(login) |
330 self._cnxs.append(self.cnx) |
368 self.cnx = repo_connect(self.repo, unicode(login), **kwargs) |
|
369 self.websession = DBAPISession(self.cnx) |
|
370 self._cnxs.append(self.cnx) |
331 if login == self.vreg.config.anonymous_user()[0]: |
371 if login == self.vreg.config.anonymous_user()[0]: |
332 self.cnx.anonymous_connection = True |
372 self.cnx.anonymous_connection = True |
|
373 if autoclose: |
|
374 return TestCaseConnectionProxy(self, self.cnx) |
333 return self.cnx |
375 return self.cnx |
334 |
376 |
335 def restore_connection(self): |
377 def restore_connection(self): |
336 if not self.cnx is self._orig_cnx[0]: |
378 if not self.cnx is self._orig_cnx[0]: |
337 if not self.cnx._closed: |
379 if not self.cnx._closed: |
497 raise |
539 raise |
498 publisher.error_handler = raise_error_handler |
540 publisher.error_handler = raise_error_handler |
499 return publisher |
541 return publisher |
500 |
542 |
501 requestcls = fake.FakeRequest |
543 requestcls = fake.FakeRequest |
502 def request(self, *args, **kwargs): |
544 def request(self, rollbackfirst=False, **kwargs): |
503 """return a web ui request""" |
545 """return a web ui request""" |
504 req = self.requestcls(self.vreg, form=kwargs) |
546 req = self.requestcls(self.vreg, form=kwargs) |
|
547 if rollbackfirst: |
|
548 self.websession.cnx.rollback() |
505 req.set_session(self.websession) |
549 req.set_session(self.websession) |
506 return req |
550 return req |
507 |
551 |
508 def remote_call(self, fname, *args): |
552 def remote_call(self, fname, *args): |
509 """remote json call simulation""" |
553 """remote json call simulation""" |
524 req.cnx.commit() |
568 req.cnx.commit() |
525 except web.Redirect: |
569 except web.Redirect: |
526 req.cnx.commit() |
570 req.cnx.commit() |
527 raise |
571 raise |
528 return result |
572 return result |
|
573 |
|
574 def req_from_url(self, url): |
|
575 """parses `url` and builds the corresponding CW-web request |
|
576 |
|
577 req.form will be setup using the url's query string |
|
578 """ |
|
579 req = self.request() |
|
580 if isinstance(url, unicode): |
|
581 url = url.encode(req.encoding) # req.setup_params() expects encoded strings |
|
582 querystring = urlparse.urlparse(url)[-2] |
|
583 params = urlparse.parse_qs(querystring) |
|
584 req.setup_params(params) |
|
585 return req |
|
586 |
|
587 def url_publish(self, url): |
|
588 """takes `url`, uses application's app_resolver to find the |
|
589 appropriate controller, and publishes the result. |
|
590 |
|
591 This should pretty much correspond to what occurs in a real CW server |
|
592 except the apache-rewriter component is not called. |
|
593 """ |
|
594 req = self.req_from_url(url) |
|
595 ctrlid, rset = self.app.url_resolver.process(req, req.relative_path(False)) |
|
596 return self.ctrl_publish(req, ctrlid) |
529 |
597 |
530 def expect_redirect(self, callback, req): |
598 def expect_redirect(self, callback, req): |
531 """call the given callback with req as argument, expecting to get a |
599 """call the given callback with req as argument, expecting to get a |
532 Redirect exception |
600 Redirect exception |
533 """ |
601 """ |
571 |
639 |
572 def assertAuthSuccess(self, req, origsession, nbsessions=1): |
640 def assertAuthSuccess(self, req, origsession, nbsessions=1): |
573 sh = self.app.session_handler |
641 sh = self.app.session_handler |
574 path, params = self.expect_redirect(lambda x: self.app.connect(x), req) |
642 path, params = self.expect_redirect(lambda x: self.app.connect(x), req) |
575 session = req.session |
643 session = req.session |
576 self.assertEquals(len(self.open_sessions), nbsessions, self.open_sessions) |
644 self.assertEqual(len(self.open_sessions), nbsessions, self.open_sessions) |
577 self.assertEquals(session.login, origsession.login) |
645 self.assertEqual(session.login, origsession.login) |
578 self.assertEquals(session.anonymous_session, False) |
646 self.assertEqual(session.anonymous_session, False) |
579 self.assertEquals(path, 'view') |
647 self.assertEqual(path, 'view') |
580 self.assertEquals(params, {'__message': 'welcome %s !' % req.user.login}) |
648 self.assertEqual(params, {'__message': 'welcome %s !' % req.user.login}) |
581 |
649 |
582 def assertAuthFailure(self, req, nbsessions=0): |
650 def assertAuthFailure(self, req, nbsessions=0): |
583 self.app.connect(req) |
651 self.app.connect(req) |
584 self.assertIsInstance(req.session, DBAPISession) |
652 self.assertIsInstance(req.session, DBAPISession) |
585 self.assertEquals(req.session.cnx, None) |
653 self.assertEqual(req.session.cnx, None) |
586 self.assertEquals(req.cnx, None) |
654 self.assertEqual(req.cnx, None) |
587 self.assertEquals(len(self.open_sessions), nbsessions) |
655 self.assertEqual(len(self.open_sessions), nbsessions) |
588 clear_cache(req, 'get_authorization') |
656 clear_cache(req, 'get_authorization') |
589 |
657 |
590 # content validation ####################################################### |
658 # content validation ####################################################### |
591 |
659 |
592 # validators are used to validate (XML, DTD, whatever) view's content |
660 # validators are used to validate (XML, DTD, whatever) view's content |
618 |
686 |
619 def view(self, vid, rset=None, req=None, template='main-template', |
687 def view(self, vid, rset=None, req=None, template='main-template', |
620 **kwargs): |
688 **kwargs): |
621 """This method tests the view `vid` on `rset` using `template` |
689 """This method tests the view `vid` on `rset` using `template` |
622 |
690 |
623 If no error occured while rendering the view, the HTML is analyzed |
691 If no error occurred while rendering the view, the HTML is analyzed |
624 and parsed. |
692 and parsed. |
625 |
693 |
626 :returns: an instance of `cubicweb.devtools.htmlparser.PageInfo` |
694 :returns: an instance of `cubicweb.devtools.htmlparser.PageInfo` |
627 encapsulation the generated HTML |
695 encapsulation the generated HTML |
628 """ |
696 """ |
631 kwargs['rset'] = rset |
699 kwargs['rset'] = rset |
632 viewsreg = self.vreg['views'] |
700 viewsreg = self.vreg['views'] |
633 view = viewsreg.select(vid, req, **kwargs) |
701 view = viewsreg.select(vid, req, **kwargs) |
634 # set explicit test description |
702 # set explicit test description |
635 if rset is not None: |
703 if rset is not None: |
636 self.set_description("testing %s, mod=%s (%s)" % ( |
704 self.set_description("testing vid=%s defined in %s with (%s)" % ( |
637 vid, view.__module__, rset.printable_rql())) |
705 vid, view.__module__, rset.printable_rql())) |
638 else: |
706 else: |
639 self.set_description("testing %s, mod=%s (no rset)" % ( |
707 self.set_description("testing vid=%s defined in %s without rset" % ( |
640 vid, view.__module__)) |
708 vid, view.__module__)) |
641 if template is None: # raw view testing, no template |
709 if template is None: # raw view testing, no template |
642 viewfunc = view.render |
710 viewfunc = view.render |
643 else: |
711 else: |
644 kwargs['view'] = view |
712 kwargs['view'] = view |
650 |
718 |
651 |
719 |
652 def _test_view(self, viewfunc, view, template='main-template', kwargs={}): |
720 def _test_view(self, viewfunc, view, template='main-template', kwargs={}): |
653 """this method does the actual call to the view |
721 """this method does the actual call to the view |
654 |
722 |
655 If no error occured while rendering the view, the HTML is analyzed |
723 If no error occurred while rendering the view, the HTML is analyzed |
656 and parsed. |
724 and parsed. |
657 |
725 |
658 :returns: an instance of `cubicweb.devtools.htmlparser.PageInfo` |
726 :returns: an instance of `cubicweb.devtools.htmlparser.PageInfo` |
659 encapsulation the generated HTML |
727 encapsulation the generated HTML |
660 """ |
728 """ |
702 else: |
770 else: |
703 default_validator = None |
771 default_validator = None |
704 validatorclass = self.content_type_validators.get(view.content_type, |
772 validatorclass = self.content_type_validators.get(view.content_type, |
705 default_validator) |
773 default_validator) |
706 if validatorclass is None: |
774 if validatorclass is None: |
707 return None |
775 return output.strip() |
708 validator = validatorclass() |
776 validator = validatorclass() |
709 if isinstance(validator, htmlparser.DTDValidator): |
777 if isinstance(validator, htmlparser.DTDValidator): |
710 # XXX remove <canvas> used in progress widget, unknown in html dtd |
778 # XXX remove <canvas> used in progress widget, unknown in html dtd |
711 output = re.sub('<canvas.*?></canvas>', '', output) |
779 output = re.sub('<canvas.*?></canvas>', '', output) |
712 return validator.parse_string(output.strip()) |
780 return validator.parse_string(output.strip()) |
784 |
852 |
785 class AutoPopulateTest(CubicWebTC): |
853 class AutoPopulateTest(CubicWebTC): |
786 """base class for test with auto-populating of the database""" |
854 """base class for test with auto-populating of the database""" |
787 __abstract__ = True |
855 __abstract__ = True |
788 |
856 |
|
857 tags = CubicWebTC.tags | Tags('autopopulated') |
|
858 |
789 pdbclass = CubicWebDebugger |
859 pdbclass = CubicWebDebugger |
790 # this is a hook to be able to define a list of rql queries |
860 # this is a hook to be able to define a list of rql queries |
791 # that are application dependent and cannot be guessed automatically |
861 # that are application dependent and cannot be guessed automatically |
792 application_rql = [] |
862 application_rql = [] |
793 |
863 |
840 try: |
910 try: |
841 cu.execute(rql, args) |
911 cu.execute(rql, args) |
842 except ValidationError, ex: |
912 except ValidationError, ex: |
843 # failed to satisfy some constraint |
913 # failed to satisfy some constraint |
844 print 'error in automatic db population', ex |
914 print 'error in automatic db population', ex |
|
915 self.session.commit_state = None # reset uncommitable flag |
845 self.post_populate(cu) |
916 self.post_populate(cu) |
846 self.commit() |
917 self.commit() |
847 |
918 |
848 def iter_individual_rsets(self, etypes=None, limit=None): |
919 def iter_individual_rsets(self, etypes=None, limit=None): |
849 etypes = etypes or self.to_test_etypes() |
920 etypes = etypes or self.to_test_etypes() |
909 |
980 |
910 # concrete class for automated application testing ############################ |
981 # concrete class for automated application testing ############################ |
911 |
982 |
912 class AutomaticWebTest(AutoPopulateTest): |
983 class AutomaticWebTest(AutoPopulateTest): |
913 """import this if you wan automatic tests to be ran""" |
984 """import this if you wan automatic tests to be ran""" |
|
985 |
|
986 tags = AutoPopulateTest.tags | Tags('web', 'generated') |
|
987 |
914 def setUp(self): |
988 def setUp(self): |
915 AutoPopulateTest.setUp(self) |
989 AutoPopulateTest.setUp(self) |
916 # access to self.app for proper initialization of the authentication |
990 # access to self.app for proper initialization of the authentication |
917 # machinery (else some views may fail) |
991 # machinery (else some views may fail) |
918 self.app |
992 self.app |