335 lines
13 KiB
Python
335 lines
13 KiB
Python
"""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 == []
|