"""Guides: block validation, revisioning, and the draft/publish split. The behaviour worth protecting hardest is the last one. A technician following a procedure on a shop floor must not have it change under them because somebody is editing it upstairs, and the only thing standing between them and that is the published pointer. """ import pytest from shopdb import create_app from shopdb.extensions import db as _db from shopdb.plugins import plugin_manager # --------------------------------------------------------------------------- # Block validation - pure, no app context needed. These rules exist to stop a # BAD SAVE becoming a guide that will not render weeks later, when the author # has moved on and the only evidence is a blank page. # --------------------------------------------------------------------------- def _block(blockid='b1', blocktype='paragraph', **extra): return dict({'id': blockid, 'type': blocktype}, **extra) def test_a_well_formed_block_list_passes(): from plugins.guides.services.blocks import validate blocks = [ _block('b1', 'heading', text='Before you start'), _block('b2', 'paragraph', text='Check the **cable** first.'), _block('b3', 'checklist', items=[{'id': 'i1', 'text': 'Power on'}]), ] assert validate(blocks) == blocks def test_a_block_without_an_id_is_refused(): """Ids are what make a tick rememberable. Without one the checkbox silently loses its state on the next visit, which reads to a user as "the checkboxes do not work" - a bug report with no stack trace and no obvious cause. """ from plugins.guides.services.blocks import validate, BlockError with pytest.raises(BlockError) as exc: validate([{'type': 'paragraph', 'text': 'no id'}]) assert 'stable id' in str(exc.value) def test_duplicate_ids_are_refused(): from plugins.guides.services.blocks import validate, BlockError with pytest.raises(BlockError) as exc: validate([_block('same'), _block('same')]) assert 'duplicate' in str(exc.value) def test_an_unknown_block_type_is_refused_and_lists_the_known_ones(): from plugins.guides.services.blocks import validate, BlockError with pytest.raises(BlockError) as exc: validate([_block('b1', 'carousel')]) message = str(exc.value) assert 'carousel' in message and 'checklist' in message def test_checklist_items_need_their_own_ids(): """Same reason as blocks: the item id is what a tick is stored against.""" from plugins.guides.services.blocks import validate, BlockError with pytest.raises(BlockError) as exc: validate([_block('b1', 'checklist', items=[{'text': 'no id'}])]) assert 'item' in str(exc.value) def test_the_error_names_which_block_is_wrong(): """An author fixing a rejected save should not have to count blocks.""" from plugins.guides.services.blocks import validate, BlockError with pytest.raises(BlockError) as exc: validate([_block('b1'), _block('b2'), {'type': 'paragraph'}]) assert str(exc.value).startswith('block 3:') def test_content_hash_covers_title_and_tags_not_just_blocks(): """Renaming or retagging a guide is a change a reader notices. Hashing only the body would drop those edits out of the history entirely. """ from plugins.guides.services.blocks import contenthash blocks = [_block('b1', 'paragraph', text='same body')] base = contenthash('Title', 'Summary', 'pxe, share', blocks) assert contenthash('Different', 'Summary', 'pxe, share', blocks) != base assert contenthash('Title', 'Summary', 'pxe', blocks) != base assert contenthash('Title', 'Different', 'pxe, share', blocks) != base def test_content_hash_ignores_tag_order_case_and_spacing(): """Otherwise reordering tags in the editor would manufacture a version.""" from plugins.guides.services.blocks import contenthash blocks = [_block()] assert (contenthash('T', '', 'PXE, Share', blocks) == contenthash('T', '', 'share ,pxe', blocks)) def test_checklist_item_count_counts_only_checklists(): from plugins.guides.services.blocks import checklistitemcount blocks = [ _block('b1', 'checklist', items=[{'id': 'i1'}, {'id': 'i2'}]), _block('b2', 'bullets', items=['not tickable']), _block('b3', 'checklist', items=[{'id': 'i3'}]), ] assert checklistitemcount(blocks) == 3 def test_plaintext_flattens_a_guide_for_search(): """People search for something written halfway down, not for the title.""" from plugins.guides.services.blocks import plaintext blocks = [ _block('b1', 'heading', text='Mapped drive'), _block('b2', 'checklist', items=[{'id': 'i1', 'text': 'Run the sync'}]), _block('b3', 'table', rows=[['left', 'right']]), ] flat = plaintext(blocks) assert 'Mapped drive' in flat and 'Run the sync' in flat and 'right' in flat # --------------------------------------------------------------------------- # Authoring: revisions, dedup and publishing. Needs an app context. # --------------------------------------------------------------------------- @pytest.fixture(scope='module') def gd_app(): saved = (plugin_manager._app, plugin_manager._db, plugin_manager.registry, plugin_manager.loader, plugin_manager.migration_manager, plugin_manager._registered_prefixes) application = create_app('testing') from plugins.guides.plugin import GuidesPlugin plugin = GuidesPlugin() pm = application.extensions['plugin_manager'] if plugin.meta.api_prefix not in pm._registered_prefixes: pm._register_plugin_components(plugin) with application.app_context(): _db.create_all() yield application _db.session.remove() _db.drop_all() (plugin_manager._app, plugin_manager._db, plugin_manager.registry, plugin_manager.loader, plugin_manager.migration_manager, plugin_manager._registered_prefixes) = saved @pytest.fixture def gd_clean(gd_app): from plugins.guides.models import Guide, GuideRevision with gd_app.app_context(): _db.session.query(GuideRevision).delete() _db.session.query(Guide).delete() _db.session.commit() yield def _newguide(title='Replacing a marker controller'): from plugins.guides.models import Guide guide = Guide(title=title, slug=title.lower().replace(' ', '-'), status='draft') _db.session.add(guide) _db.session.flush() return guide def test_the_first_save_creates_version_one(gd_app, gd_clean): from plugins.guides.services import authoring with gd_app.app_context(): guide = _newguide() guide, revision = authoring.save( guide, title='Replacing a marker controller', blocks=[_block('b1', 'paragraph', text='Power it off first.')]) _db.session.commit() assert revision is not None assert revision.revision == 1 assert guide.revision == 1 def test_saving_identical_content_writes_no_revision(gd_app, gd_clean): """Autosave would otherwise produce a hundred versions a day. A history full of identical entries hides the three edits that mattered, which is the same failure the backups plugin dedups away. """ from plugins.guides.models import GuideRevision from plugins.guides.services import authoring with gd_app.app_context(): guide = _newguide() blocks = [_block('b1', 'paragraph', text='Unchanged.')] authoring.save(guide, title='T', blocks=blocks) _db.session.commit() guide, second = authoring.save(guide, title='T', blocks=blocks) _db.session.commit() assert second is None, 'an unchanged save must not manufacture a version' assert GuideRevision.query.filter_by(guideid=guide.guideid).count() == 1 assert guide.revision == 1 def test_a_real_edit_creates_the_next_version(gd_app, gd_clean): from plugins.guides.models import GuideRevision from plugins.guides.services import authoring with gd_app.app_context(): guide = _newguide() authoring.save(guide, title='T', blocks=[_block('b1', 'paragraph', text='One.')]) _db.session.commit() guide, second = authoring.save( guide, blocks=[_block('b1', 'paragraph', text='Two.')]) _db.session.commit() assert second.revision == 2 assert GuideRevision.query.filter_by(guideid=guide.guideid).count() == 2 def test_readers_see_nothing_until_it_is_published(gd_app, gd_clean): """A half-written procedure on a shop floor is worse than no procedure.""" from plugins.guides.services import authoring with gd_app.app_context(): guide = _newguide() authoring.save(guide, title='T', blocks=[_block()]) _db.session.commit() assert authoring.readable(guide) is None def test_editing_a_published_guide_does_not_change_what_readers_see(gd_app, gd_clean): """THE point of the draft/publish split. A technician halfway through a procedure keeps reading the version that was published, even while the next one is being written. """ from plugins.guides.services import authoring with gd_app.app_context(): guide = _newguide() authoring.save(guide, title='T', blocks=[_block('b1', 'paragraph', text='Published text.')]) authoring.publish(guide) _db.session.commit() authoring.save(guide, blocks=[_block('b1', 'paragraph', text='Work in progress.')]) _db.session.commit() served = authoring.readable(guide) assert served.blocks[0]['text'] == 'Published text.' assert guide.revision == 2, 'the working copy moved on' assert served.revision == 1, 'what readers get did not' def test_publishing_promotes_the_latest_saved_revision(gd_app, gd_clean): from plugins.guides.services import authoring with gd_app.app_context(): guide = _newguide() authoring.save(guide, title='T', blocks=[_block('b1', 'paragraph', text='v1')]) authoring.save(guide, blocks=[_block('b1', 'paragraph', text='v2')]) authoring.publish(guide) _db.session.commit() assert authoring.readable(guide).blocks[0]['text'] == 'v2' assert guide.status == 'published' def test_publishing_an_empty_guide_is_refused(gd_app, gd_clean): from plugins.guides.services import authoring with gd_app.app_context(): guide = _newguide() _db.session.commit() with pytest.raises(ValueError): authoring.publish(guide) def test_unpublishing_withdraws_without_destroying_the_history(gd_app, gd_clean): """For a procedure that turns out to be wrong. The versions stay, so it can be corrected and published again, and so there is still a record of what people were being told in the meantime. """ from plugins.guides.models import GuideRevision from plugins.guides.services import authoring with gd_app.app_context(): guide = _newguide() authoring.save(guide, title='T', blocks=[_block()]) authoring.publish(guide) _db.session.commit() authoring.unpublish(guide) _db.session.commit() assert authoring.readable(guide) is None assert guide.status == 'draft' assert GuideRevision.query.filter_by(guideid=guide.guideid).count() == 1 def test_restoring_writes_a_new_version_rather_than_rewriting_history(gd_app, gd_clean): """A history you can edit is not a record of anything. Restoring brings old content forward as a NEW version, so what was published in between stays visible. """ from plugins.guides.models import GuideRevision from plugins.guides.services import authoring with gd_app.app_context(): guide = _newguide() _, first = authoring.save(guide, title='T', blocks=[_block('b1', 'paragraph', text='original')]) authoring.save(guide, blocks=[_block('b1', 'paragraph', text='mistake')]) _db.session.commit() authoring.restore(guide, first) _db.session.commit() rows = (GuideRevision.query.filter_by(guideid=guide.guideid) .order_by(GuideRevision.revision).all()) assert [r.revision for r in rows] == [1, 2, 3] assert rows[2].blocks[0]['text'] == 'original' assert 'Restored from version 1' in (rows[2].note or '') assert rows[1].blocks[0]['text'] == 'mistake', 'the mistake stays on record' def test_a_corrupt_blocks_column_renders_as_empty_rather_than_erroring(gd_app, gd_clean): """A guide that will not render is a page a person cannot read. An empty guide with its title and tags intact is a better failure than an error page, and the corruption is still visible - the editor shows nothing. """ with gd_app.app_context(): guide = _newguide() guide.blocksjson = 'not json at all' _db.session.commit() assert guide.blocks == []