A guide can be carried to another site
Guides are written on one site and needed on all of them, and the sites that matter are air-gapped from wherever the writing happens. Until now a procedure was stranded in the database it was typed into: the HTML importer could rebuild one from its original article, but anything authored in the editor had nowhere to go. flask guides export writes one .guide.json per guide, with every image the blocks reference carried inside it as base64. One file on purpose - a bundle that needs its images copied alongside it is a bundle that will eventually arrive without them. flask guides import reads them back, a directory at a time, and refuses anything that is not a bundle before it writes a row rather than half-importing a stray .json sitting in the same folder. What travels is the content: title, summary, tags, blocks, images. What does not is ids, revision history, authorship and publication state, because those belong to whichever database issued them - an imported guide starts as a draft at revision 1, owned by whoever ran the import. The SLUG does travel: it is the address somebody pastes into a work order, and the same guide should answer to the same name on every site. --replace updates the guide already carrying that slug instead of creating a second one, which is how a corrected guide is re-shipped. It drops the stored images first: the incoming bundle is the whole truth for that guide, and a guide re-shipped monthly would otherwise accumulate orphans nothing points at. Bundles refuse a version they are too old to read rather than importing the parts they recognise. A newer site's guide is not a guide this one can be trusted to render. Proven by round-tripping a five-guide export back into the same site, including one carrying two PNG screenshots, and reading it in a browser.
This commit is contained in:
146
tests/test_plugins/test_guides_bundle.py
Normal file
146
tests/test_plugins/test_guides_bundle.py
Normal file
@@ -0,0 +1,146 @@
|
||||
"""Carrying a guide from one site to another.
|
||||
|
||||
Sites are separate databases, and the ones that matter are air-gapped from
|
||||
wherever the guide was written. These tests are about what has to survive that
|
||||
trip and what must not: the blocks and their images travel, the ids do not.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from plugins.guides.services import bundle as bundleservice
|
||||
|
||||
|
||||
PNG = base64.b64decode(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk'
|
||||
'YPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==')
|
||||
|
||||
|
||||
class FakeImage:
|
||||
def __init__(self, imageid, data=PNG, mimetype='image/png',
|
||||
filename='shot.png'):
|
||||
self.guideimageid = imageid
|
||||
self.data = data
|
||||
self.mimetype = mimetype
|
||||
self.filename = filename
|
||||
|
||||
|
||||
class FakeGuide:
|
||||
def __init__(self, blocks, slug='a-guide', title='A Guide',
|
||||
summary='What it does', tags='shopfloor, printers'):
|
||||
self.slug = slug
|
||||
self.title = title
|
||||
self.summary = summary
|
||||
self.tags = tags
|
||||
self.blocks = blocks
|
||||
|
||||
|
||||
def test_a_bundle_carries_what_the_far_side_needs():
|
||||
guide = FakeGuide([{'id': 'b001', 'type': 'paragraph', 'text': 'Hello'}])
|
||||
payload = bundleservice.build(guide, [])
|
||||
assert payload['format'] == 'shopdb-guide'
|
||||
assert payload['slug'] == 'a-guide'
|
||||
assert payload['title'] == 'A Guide'
|
||||
assert payload['summary'] == 'What it does'
|
||||
assert payload['tags'] == 'shopfloor, printers'
|
||||
assert payload['blocks'][0]['text'] == 'Hello'
|
||||
|
||||
|
||||
def test_ids_do_not_travel():
|
||||
"""A guide's id and revision belong to the database that issued them."""
|
||||
guide = FakeGuide([{'id': 'b001', 'type': 'paragraph', 'text': 'Hello'}])
|
||||
payload = bundleservice.build(guide, [])
|
||||
assert 'guideid' not in payload
|
||||
assert 'revision' not in payload
|
||||
assert 'publishedat' not in payload
|
||||
|
||||
|
||||
def test_an_image_travels_inside_the_file_not_beside_it():
|
||||
"""One file. A bundle that needs its images copied alongside it is a
|
||||
bundle that will eventually arrive without them."""
|
||||
guide = FakeGuide([{'id': 'b001', 'type': 'image', 'imageid': 42,
|
||||
'alt': 'the screen'}])
|
||||
payload = bundleservice.build(guide, [FakeImage(42)])
|
||||
|
||||
assert len(payload['images']) == 1
|
||||
assert payload['images'][0]['mimetype'] == 'image/png'
|
||||
assert base64.b64decode(payload['images'][0]['data']) == PNG
|
||||
|
||||
block = payload['blocks'][0]
|
||||
assert 'imageid' not in block, 'a local id means nothing on the far side'
|
||||
assert block['imagetoken'] == payload['images'][0]['token']
|
||||
assert block['alt'] == 'the screen'
|
||||
|
||||
|
||||
def test_the_same_image_used_twice_travels_once():
|
||||
guide = FakeGuide([
|
||||
{'id': 'b001', 'type': 'image', 'imageid': 42},
|
||||
{'id': 'b002', 'type': 'image', 'imageid': 42},
|
||||
])
|
||||
payload = bundleservice.build(guide, [FakeImage(42)])
|
||||
assert len(payload['images']) == 1
|
||||
assert payload['blocks'][0]['imagetoken'] == payload['blocks'][1]['imagetoken']
|
||||
|
||||
|
||||
def test_an_image_the_guide_does_not_use_is_left_behind():
|
||||
"""An orphaned paste from a draft is not part of the procedure."""
|
||||
guide = FakeGuide([{'id': 'b001', 'type': 'paragraph', 'text': 'Hi'}])
|
||||
payload = bundleservice.build(guide, [FakeImage(42), FakeImage(43)])
|
||||
assert payload['images'] == []
|
||||
|
||||
|
||||
def test_a_block_pointing_at_a_missing_image_is_dropped():
|
||||
"""It would render as 'Image unavailable' on the far side, which reads as
|
||||
a failed import rather than as missing data."""
|
||||
guide = FakeGuide([
|
||||
{'id': 'b001', 'type': 'paragraph', 'text': 'Before'},
|
||||
{'id': 'b002', 'type': 'image', 'imageid': 99},
|
||||
{'id': 'b003', 'type': 'paragraph', 'text': 'After'},
|
||||
])
|
||||
payload = bundleservice.build(guide, [])
|
||||
assert [b['type'] for b in payload['blocks']] == ['paragraph', 'paragraph']
|
||||
|
||||
|
||||
def test_a_bundle_survives_the_round_trip():
|
||||
guide = FakeGuide([
|
||||
{'id': 'b001', 'type': 'heading', 'text': 'Steps'},
|
||||
{'id': 'b002', 'type': 'image', 'imageid': 7},
|
||||
{'id': 'b003', 'type': 'checklist',
|
||||
'items': [{'id': 'i1', 'text': 'Tick me'}]},
|
||||
])
|
||||
text = bundleservice.dumps(bundleservice.build(guide, [FakeImage(7)]))
|
||||
back = bundleservice.loads(text)
|
||||
|
||||
assert back['slug'] == 'a-guide'
|
||||
assert [b['type'] for b in back['blocks']] == ['heading', 'image', 'checklist']
|
||||
assert back['images'][0]['data'] == PNG, 'decoded back to bytes, not base64'
|
||||
assert back['blocks'][2]['items'][0]['text'] == 'Tick me'
|
||||
|
||||
|
||||
def test_something_that_is_not_a_bundle_is_refused_before_anything_is_written():
|
||||
with pytest.raises(bundleservice.BundleError):
|
||||
bundleservice.loads('{"hello": "world"}')
|
||||
with pytest.raises(bundleservice.BundleError):
|
||||
bundleservice.loads('not json at all')
|
||||
with pytest.raises(bundleservice.BundleError):
|
||||
bundleservice.loads(json.dumps({'format': 'shopdb-guide', 'version': 1}))
|
||||
|
||||
|
||||
def test_a_bundle_from_a_newer_site_is_refused_rather_than_half_read():
|
||||
newer = json.dumps({'format': 'shopdb-guide', 'version': 99,
|
||||
'slug': 'x', 'blocks': []})
|
||||
with pytest.raises(bundleservice.BundleError) as error:
|
||||
bundleservice.loads(newer)
|
||||
assert 'newer than this site can read' in str(error.value)
|
||||
|
||||
|
||||
def test_an_unreadable_image_names_itself():
|
||||
broken = json.dumps({
|
||||
'format': 'shopdb-guide', 'version': 1, 'slug': 'x', 'blocks': [],
|
||||
'images': [{'token': 'img001', 'mimetype': 'image/png',
|
||||
'filename': 'x.png', 'data': 'not base64 !!'}]})
|
||||
with pytest.raises(bundleservice.BundleError) as error:
|
||||
bundleservice.loads(broken)
|
||||
assert 'img001' in str(error.value)
|
||||
Reference in New Issue
Block a user