← back to Shopify Room Mockup
TK-12096: double-run guard — refuse push when ledger has a live upload for product+ticket (--force overrides) + negative test; ledger records the 5 duplicate deletions
e6427c04f8258868719e3c74ab6da9fa5a7cbb4d · 2026-09-26 09:22:42 -0700 · Steve Abrams
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VqJiAb5GBhYitB8Fr17eGx
Files touched
M shopify-room-mockup.pyA tests/test_ledger_guard.py
Diff
commit e6427c04f8258868719e3c74ab6da9fa5a7cbb4d
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Sat Sep 26 09:22:42 2026 -0700
TK-12096: double-run guard — refuse push when ledger has a live upload for product+ticket (--force overrides) + negative test; ledger records the 5 duplicate deletions
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VqJiAb5GBhYitB8Fr17eGx
---
shopify-room-mockup.py | 34 +++++++++++++++++++++
tests/test_ledger_guard.py | 74 ++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 108 insertions(+)
diff --git a/shopify-room-mockup.py b/shopify-room-mockup.py
index c306db3..2240720 100644
--- a/shopify-room-mockup.py
+++ b/shopify-room-mockup.py
@@ -46,6 +46,8 @@ OPTIONS:
the :3075 SSH room service is retired)
--skip-existing skip products that already have a room mockup
(detected by media alt containing "in a room")
+ --force push even if out/room-mockup-ledger.jsonl already has a live
+ upload for that product+ticket (default: refuse — double-run guard)
--alt-suffix "shown in a living room" alt-text suffix for the new image
--limit N cap number of products processed
--dry-run list targets + render, but DON'T upload
@@ -195,6 +197,32 @@ def append_ledger(ledger_path, row):
os.fsync(f.fileno())
+def ledgered_live(ledger_path, ticket):
+ """Product gids that already have a LIVE ledgered room upload for this ticket.
+
+ A row with a media_id and no `action` (or action=='upload') is an upload; an
+ action=='deleted' row for the same media_id cancels it. This is the
+ double-run guard (TK-12096: two runs 3 min apart pushed duplicate images to
+ 5 live products). A missing ledger means nothing is ledgered yet; an
+ UNREADABLE ledger raises, so the guard can never fail open on a bad file.
+ """
+ if not os.path.exists(ledger_path):
+ return set()
+ live = {}
+ with open(ledger_path) as f:
+ for line in f:
+ if not line.strip():
+ continue
+ r = json.loads(line)
+ if r.get('ticket') != ticket or not r.get('media_id'):
+ continue
+ if r.get('action', 'upload') == 'deleted':
+ live.pop(r['media_id'], None)
+ else:
+ live[r['media_id']] = r['product_gid']
+ return set(live.values())
+
+
def undo_cmd_for(store, token_env, media_id, product_gid):
return (f'python3 shopify-room-mockup.py --store {store} --token-env {token_env} '
f'--delete-media {media_id} --product {product_gid}')
@@ -250,6 +278,8 @@ def main():
help='UNDO utility mode: productDeleteMedia on this media id/gid (requires --product). '
'This is exactly the invocation the ledger writes as undo_cmd.')
ap.add_argument('--product', default=None, help='product id/gid, used with --delete-media')
+ ap.add_argument('--force', action='store_true',
+ help='push even when the ledger already has a live room upload for this product+ticket')
args = ap.parse_args()
if args.delete_media:
@@ -280,9 +310,13 @@ def main():
prods = prods[:args.limit]
print(f'{len(prods)} product(s) selected on {args.store}.')
+ already = set() if args.force else ledgered_live(args.ledger, args.ticket)
done = skipped = failed = 0
for p in prods:
tag = p['title']
+ if p['id'] in already:
+ print(f' ⏭ SKIP (already ledgered for {args.ticket}; --force to override): {tag}')
+ skipped += 1; continue
if args.skip_existing and p['has_room']:
print(f' ⏭ SKIP (has room): {tag}'); skipped += 1; continue
if not p['image_url']:
diff --git a/tests/test_ledger_guard.py b/tests/test_ledger_guard.py
new file mode 100644
index 0000000..3939473
--- /dev/null
+++ b/tests/test_ledger_guard.py
@@ -0,0 +1,74 @@
+"""Negative test for the TK-12096 double-run guard: a second run over products the
+ledger already holds must be a no-op (no render, no upload); --force and a
+ledgered deletion re-open it. No network, no Shopify: select/render/upload stubbed."""
+import importlib.util, io, json, os, sys, tempfile, unittest
+from contextlib import redirect_stdout
+
+HERE = os.path.dirname(os.path.abspath(__file__))
+spec = importlib.util.spec_from_file_location('srm', os.path.join(HERE, '..', 'shopify-room-mockup.py'))
+srm = importlib.util.module_from_spec(spec); spec.loader.exec_module(srm)
+
+GIDS = ['gid://shopify/Product/1', 'gid://shopify/Product/2']
+
+
+class Guard(unittest.TestCase):
+ def setUp(self):
+ self.tmp = tempfile.mkdtemp()
+ self.ledger = os.path.join(self.tmp, 'ledger.jsonl')
+ self.uploads = []
+ srm.load_token = lambda env: 'x'
+ srm.select_products = lambda sh, a: [
+ {'id': g, 'title': g, 'image_url': 'http://img', 'has_room': False} for g in GIDS]
+ srm.render_room = lambda *a, **k: b'png'
+ n = iter(range(100, 200))
+ def fake_upload(sh, pid, buf, alt):
+ self.uploads.append(pid)
+ return True, [{'id': f'gid://shopify/MediaImage/{next(n)}'}]
+ srm.upload_media = fake_upload
+
+ def run_tool(self, *extra):
+ argv = ['x', '--ids', '1,2', '--ledger', self.ledger, '--ticket', 'TK-T', *extra]
+ old, sys.argv = sys.argv, argv
+ out = io.StringIO()
+ try:
+ with redirect_stdout(out):
+ srm.main()
+ finally:
+ sys.argv = old
+ return out.getvalue()
+
+ def test_second_run_is_noop(self):
+ self.run_tool()
+ self.assertEqual(self.uploads, GIDS)
+ out = self.run_tool()
+ self.assertEqual(self.uploads, GIDS, 'second run must not upload again')
+ self.assertIn('added=0 skipped=2', out)
+
+ def test_force_overrides(self):
+ self.run_tool()
+ self.run_tool('--force')
+ self.assertEqual(len(self.uploads), 4)
+
+ def test_other_ticket_not_blocked(self):
+ self.run_tool()
+ self.run_tool('--ticket', 'TK-OTHER')
+ self.assertEqual(len(self.uploads), 4)
+
+ def test_deleted_row_reopens_product(self):
+ self.run_tool()
+ with open(self.ledger) as fh: rows = [json.loads(l) for l in fh]
+ with open(self.ledger, 'a') as f:
+ f.write(json.dumps({'ticket': 'TK-T', 'action': 'deleted',
+ 'product_gid': rows[0]['product_gid'], 'media_id': rows[0]['media_id']}) + '\n')
+ self.run_tool()
+ self.assertEqual(self.uploads, GIDS + [GIDS[0]])
+
+ def test_corrupt_ledger_fails_closed(self):
+ open(self.ledger, 'w').write('{not json\n')
+ with self.assertRaises(Exception):
+ self.run_tool()
+ self.assertEqual(self.uploads, [])
+
+
+if __name__ == '__main__':
+ unittest.main()
← 3a3e31e TK-12090 Fix 1: ledger the media id upload_media() already r
·
back to Shopify Room Mockup
·
(newest)