[object Object]

← 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

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)