stackchain-dashboard/tests/test_conversation_photo_drafts.py
timmy 31f2c71575
All checks were successful
CI / lint (pull_request) Successful in 2m1s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 57s
CI / release-candidate (pull_request) Has been skipped
feat: preserve My Work photo drafts (Closes #963)
2026-08-16 12:34:01 +00:00

165 lines
7.2 KiB
Python

import json
import subprocess
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
STORE = ROOT / "frontend" / "conversation-reply-draft-store.js"
COORDINATOR = ROOT / "frontend" / "conversation-photo-drafts.js"
DASHBOARD = ROOT / "frontend" / "dashboard.js"
INDEX = ROOT / "frontend" / "index.html"
REGISTRY = ROOT / "frontend" / "private-data-registry.js"
WORKER = ROOT / "frontend" / "service-worker.js"
BUNDLE = ROOT / "src" / "frontend_bundle.py"
def run_node(script: str) -> str:
return subprocess.run(
["node", "-e", script], check=True, capture_output=True, text=True
).stdout
def test_store_isolates_complete_photo_bundles_by_account_and_conversation():
script = f"""
const createStore = require({json.dumps(str(STORE))});
const records = new Map();
let login = 'timmy';
const transaction = async (operation, key, value) => {{
if (operation === 'put') records.set(key, structuredClone(value));
if (operation === 'get') return records.has(key) ? structuredClone(records.get(key)) : null;
if (operation === 'delete') records.delete(key);
}};
const store = createStore({{transaction, getOwnerLogin:()=>login}});
const issue = {{kind:'issue', repository:'stackchain/dashboard', number:963}};
const pull = {{kind:'pull', repository:'stackchain/dashboard', number:963}};
const update = {{kind:'update', notificationId:4815}};
const photos = [
{{filename:'before.webp', contentType:'image/webp', blob:new Blob(['before']), note:'before', operationId:'op-1'}},
{{filename:'after.webp', contentType:'image/webp', blob:new Blob(['after']), note:'after', operationId:'op-2', confirmed:{{markdown:'![after](saved)'}}}},
];
(async()=>{{
await store.save(issue, photos);
await store.save(pull, [photos[1]]);
await store.save(update, [photos[0]]);
const restored = await store.load(issue);
const pullDraft = await store.load(pull);
const updateDraft = await store.load(update);
login = 'alexander';
const wrongAccount = await store.load(issue);
process.stdout.write(JSON.stringify({{
restored:await Promise.all(restored.map(async photo=>({{...photo,blobText:await photo.blob.text(),blob:undefined}}))),
pullCount:pullDraft.length, updateCount:updateDraft.length, wrongAccount, recordCount:records.size,
}}));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
output = json.loads(run_node(script))
assert output == {
"restored": [
{
"filename": "before.webp",
"contentType": "image/webp",
"note": "before",
"operationId": "op-1",
"blobText": "before",
},
{
"filename": "after.webp",
"contentType": "image/webp",
"note": "after",
"operationId": "op-2",
"confirmed": {"markdown": "![after](saved)"},
"blobText": "after",
},
],
"pullCount": 1,
"updateCount": 1,
"wrongAccount": None,
"recordCount": 3,
}
def test_coordinator_ignores_stale_restores_and_clears_only_after_durable_completion():
script = f"""
const createCoordinator = require({json.dumps(str(COORDINATOR))});
const waits = new Map();
const removed = [];
const restored = [];
let current = [{{filename:'current.webp',contentType:'image/webp',blob:new Blob(['current'])}}];
const store = {{
load:target=>new Promise(resolve=>waits.set(target.number, resolve)),
save:async()=>null,
remove:async target=>removed.push(target.number || target.notificationId),
}};
const controller = {{
serialize:async()=>current,
restore:value=>restored.push(value[0].filename),
clear:()=>{{current=[];}},
}};
const drafts = createCoordinator({{store, lanes:{{issue:{{controller,onError:()=>{{}}}}}}}});
(async()=>{{
const first = drafts.open('issue', {{kind:'issue',repository:'stackchain/dashboard',number:1}});
const second = drafts.open('issue', {{kind:'issue',repository:'stackchain/dashboard',number:2}});
await Promise.resolve();
waits.get(1)([{{filename:'stale.webp',contentType:'image/webp',blob:new Blob(['stale'])}}]);
waits.get(2)([{{filename:'fresh.webp',contentType:'image/webp',blob:new Blob(['fresh'])}}]);
await Promise.all([first, second]);
await drafts.complete('issue');
process.stdout.write(JSON.stringify({{restored,removed,hasTarget:drafts.hasTarget('issue')}}));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
output = json.loads(run_node(script))
assert output == {"restored": ["fresh.webp"], "removed": [2], "hasTarget": False}
def test_coordinator_checkpoints_previous_target_before_switching_conversations():
script = f"""
const createCoordinator = require({json.dumps(str(COORDINATOR))});
const events = [];
let current = [{{filename:'first.webp',contentType:'image/webp',blob:new Blob(['first'])}}];
const store = {{
load:async target=>target.number === 2 ? [{{filename:'second.webp',contentType:'image/webp',blob:new Blob(['second'])}}] : null,
save:async (target,attachments)=>events.push('save:' + target.number + ':' + attachments[0].filename),
remove:async()=>null,
}};
const controller = {{
serialize:async()=>current,
restore:value=>{{current=value;events.push('restore:' + value[0].filename);}},
clear:()=>{{current=[];}},
}};
const drafts = createCoordinator({{store, lanes:{{issue:{{controller}}}}}});
(async()=>{{
await drafts.open('issue', {{kind:'issue',repository:'stackchain/dashboard',number:1}});
current=[{{filename:'first.webp',contentType:'image/webp',blob:new Blob(['first'])}}];
await drafts.switchTo('issue', {{kind:'issue',repository:'stackchain/dashboard',number:2}});
process.stdout.write(JSON.stringify(events));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
assert json.loads(run_node(script)) == ["save:1:first.webp", "restore:second.webp"]
def test_my_work_wires_durable_photo_drafts_into_all_conversation_boundaries():
dashboard = DASHBOARD.read_text()
html = INDEX.read_text()
registry = REGISTRY.read_text()
worker = WORKER.read_text()
bundle = BUNDLE.read_text()
assert '<script src="static/conversation-reply-draft-store.js"></script>' in html
assert '<script src="static/conversation-photo-drafts.js"></script>' in html
assert "'stackchain-conversation-reply-drafts-v1'" in registry
assert "BASE + 'static/conversation-reply-draft-store.js'" in worker
assert "BASE + 'static/conversation-photo-drafts.js'" in worker
assert '"static/conversation-reply-draft-store.js"' in bundle
assert '"static/conversation-photo-drafts.js"' in bundle
assert "conversationPhotoDrafts.switchTo('issue'," in dashboard
assert "conversationPhotoDrafts.switchTo('pull'," in dashboard
assert "conversationPhotoDrafts.switchTo('update'," in dashboard
assert "conversationPhotoDrafts.leave('issue')" in dashboard
assert "conversationPhotoDrafts.leave('pull')" in dashboard
assert "conversationPhotoDrafts.leave('update')" in dashboard
assert "await conversationPhotoDrafts.complete(kind)" in dashboard
assert "await conversationPhotoDrafts.complete('issue')" in dashboard
assert "await conversationPhotoDrafts.complete('pull')" in dashboard
assert "await conversationPhotoDrafts.complete('update')" in dashboard