feat: notify pending Human Gate decisions (Closes #1437)
This commit is contained in:
1 parent
ad15f15e3c
commit
7158e00b97
13 files changed
+494
-17
No files matched your search
@@ -88,6 +88,27 @@ console.log(JSON.stringify({values, clears}));
|
||||
assert result == {"values": [3, 5, 3, 2, 7], "clears": 1}
|
||||
|
||||
|
||||
def test_app_badge_combines_pending_human_gates_with_other_review_channels():
|
||||
result = run_badge("""
|
||||
const values = [];
|
||||
let clears = 0;
|
||||
const controller = createMobileAppBadge({
|
||||
control:{checked:true, disabled:false, addEventListener() {}},
|
||||
status:{textContent:''}, container:{hidden:false},
|
||||
navigator:{async setAppBadge(value) { values.push(value); }, async clearAppBadge() { clears++; }},
|
||||
storage:{getItem() { return 'true'; }, setItem() {}, removeItem() {}},
|
||||
});
|
||||
controller.start();
|
||||
await controller.reconcile('updates', 3, true);
|
||||
await controller.reconcile('human-gates', 2, true);
|
||||
await controller.reconcile('following', 1, true);
|
||||
await controller.reconcile('human-gates', 0, true);
|
||||
console.log(JSON.stringify({values, clears}));
|
||||
""")
|
||||
|
||||
assert result == {"values": [3, 5, 6, 4], "clears": 0}
|
||||
|
||||
|
||||
def test_app_badge_hides_unsupported_device_control_without_touching_storage():
|
||||
result = run_badge("""
|
||||
const control = {checked:false, disabled:false, addEventListener() { throw new Error('must not wire'); }};
|
||||
|
||||
@@ -34,6 +34,11 @@ const followingControl = {
|
||||
addEventListener:(_name, callback) => state.followingChange = callback,
|
||||
};
|
||||
const followingStatus = {set textContent(value) { state.followingText = value; }, get textContent() { return state.followingText; }};
|
||||
const humanGateControl = {
|
||||
checked:false, disabled:false,
|
||||
addEventListener:(_name, callback) => state.humanGateChange = callback,
|
||||
};
|
||||
const humanGateStatus = {set textContent(value) { state.humanGateText = value; }, get textContent() { return state.humanGateText; }};
|
||||
const quietControl = {checked:false, disabled:false, addEventListener:(_name, callback) => state.quietChange = callback};
|
||||
const quietStart = {value:'22:00', disabled:false, addEventListener:(_name, callback) => state.quietStartChange = callback};
|
||||
const quietEnd = {value:'07:00', disabled:false, addEventListener:(_name, callback) => state.quietEndChange = callback};
|
||||
@@ -52,6 +57,7 @@ const feature = createPushNotifications({
|
||||
control, status, testControl, deadlineControl, deadlineStatus, deadlineHour, deadlineDays,
|
||||
startDayControl, startDayStatus, startDayHour,
|
||||
followingControl, followingStatus,
|
||||
humanGateControl, humanGateStatus,
|
||||
quietControl, quietStart, quietEnd, quietStatus,
|
||||
deadlineSnooze, deadlineSnoozeStatus, deadlineSnoozeReview,
|
||||
onReviewDeadlines:() => { state.reviewed = true; },
|
||||
@@ -119,6 +125,31 @@ def test_device_settings_render_and_wire_the_following_alert_preference():
|
||||
assert "followingStatus:qs('#push-following-status')" in dashboard
|
||||
|
||||
|
||||
def test_human_gate_alert_toggle_is_opt_in_and_wired_for_touch_settings():
|
||||
result = run_scenario("""
|
||||
state.server = {available:true,subscribed:true,human_gates_enabled:false,following_enabled:true,public_key:'AQID'};
|
||||
state.current = existing;
|
||||
await feature.init();
|
||||
humanGateControl.checked = true;
|
||||
await state.humanGateChange();
|
||||
process.stdout.write(JSON.stringify({requests:state.requests, checked:humanGateControl.checked, following:followingControl.checked, text:state.humanGateText}));
|
||||
""")
|
||||
|
||||
assert result["checked"] is True
|
||||
assert result["following"] is True
|
||||
assert result["requests"][-1][0:2] == ["api/v1/push-subscription/human-gates", "PUT"]
|
||||
assert json.loads(result["requests"][-1][2]) == {"enabled": True}
|
||||
assert result["text"] == "Human Gate decision alerts enabled for this device."
|
||||
|
||||
index = INDEX.read_text()
|
||||
dashboard = DASHBOARD.read_text()
|
||||
assert 'for="push-human-gates"' in index
|
||||
assert 'id="push-human-gates" type="checkbox"' in index
|
||||
assert 'id="push-human-gates-status" role="status" aria-live="polite"' in index
|
||||
assert "humanGateControl:qs('#push-human-gates')" in dashboard
|
||||
assert "humanGateStatus:qs('#push-human-gates-status')" in dashboard
|
||||
|
||||
|
||||
def test_quiet_hours_are_restored_and_saved_as_one_local_schedule():
|
||||
result = run_scenario("""
|
||||
state.server = {available:true,subscribed:true,quiet_hours_enabled:true,quiet_hours_start:'21:30',quiet_hours_end:'06:45',quiet_hours_timezone:'America/New_York',public_key:'AQID'};
|
||||
|
||||
@@ -63,6 +63,59 @@ def test_following_alert_preferences_are_opt_in_and_checkpoint_each_device(tmp_p
|
||||
assert store.following_preferences("session-b") == {"enabled": False}
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_human_gate_dispatch_is_opt_in_private_deduplicated_and_session_bound(tmp_path):
|
||||
dispatch = getattr(__import__("src.push_notifications", fromlist=["dispatch_human_gate_changes"]), "dispatch_human_gate_changes", None)
|
||||
assert callable(dispatch), "Human Gate push dispatcher is missing"
|
||||
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
||||
for session_id in ("active", "revoked", "disabled"):
|
||||
store.upsert(session_id, {
|
||||
"endpoint": f"https://push.example/{session_id}",
|
||||
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
|
||||
})
|
||||
store.set_human_gate_preferences("active", enabled=True)
|
||||
store.set_human_gate_preferences("revoked", enabled=True)
|
||||
sent = []
|
||||
|
||||
pending_count = 2
|
||||
|
||||
async def pending_gates():
|
||||
return {"complete": True, "count": pending_count, "items": [{
|
||||
"title": "Secret launch", "artifact_url": "https://secret.example/token",
|
||||
"candidate_hash": "private-hash",
|
||||
}]}
|
||||
|
||||
async def send(subscription, payload):
|
||||
sent.append((subscription["endpoint"], json.loads(payload)))
|
||||
|
||||
async def statuses(session_ids):
|
||||
return {item: ("active" if item == "active" else "revoked") for item in session_ids}
|
||||
|
||||
configuration = PushConfiguration("public", "private", "mailto:ops@example.com")
|
||||
assert await dispatch(
|
||||
store, configuration, pending_gates, send, session_statuses=statuses
|
||||
) == 1
|
||||
assert sent == [("https://push.example/active", {
|
||||
"title": "2 release decisions are waiting",
|
||||
"body": "Open Human Gates to review them.",
|
||||
"route": "#/my-work/human-gates",
|
||||
"tag": "stackchain-human-gates-2",
|
||||
"human_gate_count": 2,
|
||||
})]
|
||||
assert "secret" not in json.dumps(sent).lower()
|
||||
assert "private-hash" not in json.dumps(sent).lower()
|
||||
assert await dispatch(
|
||||
store, configuration, pending_gates, send, session_statuses=statuses
|
||||
) == 0
|
||||
pending_count = 0
|
||||
assert await dispatch(
|
||||
store, configuration, pending_gates, send, session_statuses=statuses
|
||||
) == 0
|
||||
assert store.human_gate_notification_devices()[0].delivered_count == 0
|
||||
assert len(sent) == 1
|
||||
assert store.subscription_for_session("revoked") is None
|
||||
|
||||
|
||||
def test_quiet_hours_hold_unread_revisions_then_mark_one_catch_up_delivery(tmp_path):
|
||||
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
||||
store.upsert("session-a", {
|
||||
@@ -233,6 +286,31 @@ async def test_authenticated_device_controls_following_alerts_independently(tmp_
|
||||
assert store.start_day_preferences("session-a")["enabled"] is False
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_authenticated_device_controls_human_gate_alerts_independently(tmp_path, monkeypatch):
|
||||
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
||||
store.upsert("session-a", {
|
||||
"endpoint": "https://push.example/session-a",
|
||||
"keys": {"p256dh": "public-key", "auth": "auth-secret"},
|
||||
})
|
||||
monkeypatch.setattr(main, "_push_subscription_store", store)
|
||||
|
||||
async def management_id(_session):
|
||||
return "session-a"
|
||||
|
||||
monkeypatch.setattr(main.dashboard_auth, "session_management_id", management_id)
|
||||
request = SimpleNamespace(state=SimpleNamespace(dashboard_session=object()))
|
||||
payload_type = getattr(main, "HumanGateNotificationPayload", None)
|
||||
endpoint = getattr(main, "update_human_gate_notifications", None)
|
||||
assert payload_type is not None and callable(endpoint), "Human Gate push preference API is missing"
|
||||
|
||||
result = await endpoint(payload_type(enabled=True), request)
|
||||
|
||||
assert result == {"human_gates_enabled": True}
|
||||
assert (await main.push_status(request))["human_gates_enabled"] is True
|
||||
assert store.following_preferences("session-a")["enabled"] is False
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_authenticated_device_persists_validated_quiet_hours(tmp_path, monkeypatch):
|
||||
store = PushSubscriptionStore(tmp_path / "push.sqlite3")
|
||||
@@ -699,6 +777,7 @@ async def test_push_poll_authorizes_delivery_against_managed_session(monkeypatch
|
||||
monkeypatch.setattr(main.asyncio, "sleep", no_wait)
|
||||
monkeypatch.setattr(main, "dispatch_unread_updates", stop_after_capture)
|
||||
monkeypatch.setattr(main, "dispatch_following_changes", hold_dispatch)
|
||||
monkeypatch.setattr(main, "dispatch_human_gate_changes", hold_dispatch)
|
||||
monkeypatch.setattr(main, "dispatch_deadline_reminders", hold_dispatch)
|
||||
monkeypatch.setattr(main, "dispatch_start_day_reminders", hold_dispatch)
|
||||
|
||||
@@ -721,8 +800,8 @@ async def test_push_poll_still_dispatches_deadlines_when_unread_dispatch_fails(m
|
||||
async def no_wait(_seconds):
|
||||
nonlocal sleeps
|
||||
sleeps += 1
|
||||
if sleeps <= 4:
|
||||
if sleeps == 4:
|
||||
if sleeps <= 5:
|
||||
if sleeps == 5:
|
||||
first_tick.set()
|
||||
await first_tick.wait()
|
||||
else:
|
||||
@@ -741,16 +820,20 @@ async def test_push_poll_still_dispatches_deadlines_when_unread_dispatch_fails(m
|
||||
async def dispatch_following(*_args, **_kwargs):
|
||||
calls.append("following")
|
||||
|
||||
async def dispatch_human_gates(*_args, **_kwargs):
|
||||
calls.append("human-gates")
|
||||
|
||||
monkeypatch.setattr(main.asyncio, "sleep", no_wait)
|
||||
monkeypatch.setattr(main, "dispatch_unread_updates", fail_unread)
|
||||
monkeypatch.setattr(main, "dispatch_following_changes", dispatch_following)
|
||||
monkeypatch.setattr(main, "dispatch_human_gate_changes", dispatch_human_gates)
|
||||
monkeypatch.setattr(main, "dispatch_deadline_reminders", dispatch_deadlines)
|
||||
monkeypatch.setattr(main, "dispatch_start_day_reminders", dispatch_start_day)
|
||||
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await main._push_poll_loop()
|
||||
|
||||
assert sorted(calls) == ["deadline", "following", "start-day", "unread"]
|
||||
assert sorted(calls) == ["deadline", "following", "human-gates", "start-day", "unread"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@@ -776,6 +859,7 @@ async def test_push_poll_deadlines_continue_while_unread_dispatch_is_blocked(mon
|
||||
monkeypatch.setattr(main.asyncio, "sleep", no_wait)
|
||||
monkeypatch.setattr(main, "dispatch_unread_updates", blocked_unread)
|
||||
monkeypatch.setattr(main, "dispatch_following_changes", dispatch_start_day)
|
||||
monkeypatch.setattr(main, "dispatch_human_gate_changes", dispatch_start_day)
|
||||
monkeypatch.setattr(main, "dispatch_deadline_reminders", dispatch_deadlines)
|
||||
monkeypatch.setattr(main, "dispatch_start_day_reminders", dispatch_start_day)
|
||||
|
||||
@@ -802,7 +886,7 @@ async def test_push_poll_uses_a_lower_independent_deadline_cadence(monkeypatch):
|
||||
|
||||
await main._push_poll_loop()
|
||||
|
||||
assert sorted(intervals) == [30.0, 30.0, 600.0, 600.0]
|
||||
assert sorted(intervals) == [30.0, 30.0, 30.0, 600.0, 600.0]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@@ -1572,6 +1656,7 @@ async def test_authenticated_device_can_subscribe_report_status_and_unsubscribe(
|
||||
"start_day_timezone": "UTC",
|
||||
"start_day_reminder_hour": 9,
|
||||
"following_enabled": False,
|
||||
"human_gates_enabled": False,
|
||||
"quiet_hours_enabled": False,
|
||||
"quiet_hours_start": "22:00",
|
||||
"quiet_hours_end": "07:00",
|
||||
|
||||
@@ -14,7 +14,7 @@ def run_worker_scenario(scenario: str) -> dict:
|
||||
const fs = require('fs');
|
||||
const vm = require('vm');
|
||||
const listeners = {{}};
|
||||
const state = {{ added: [], addAttempts: [], individuallyAdded: [], failedAdds: [], deleted: [], deletedDatabases: [], claimed: false, skipped: false, fetches: [], puts: [], migrated: [], activationOrder: [], oldCachedAssets: {{}}, sharedRecords: {{}}, todayCommands: {{}}, failTodayCommandPut: false, failSharedPut: false, backgroundFlushes: 0, backgroundResumes: 0, outboxPurges: 0, outboxLifecycle: [], notifications: [], closedNotifications: 0, focused: [], opened: [], appBadges: [], clearedAppBadges: 0, badgeEnabled: false, badgeCounts: {{updates:0, following:0}}, failFetch: false, stallFetch: false, lateFetch: false, fetchAborted: false, fetchStatus: 200, fetchRedirected: false, cachedBody: null }};
|
||||
const state = {{ added: [], addAttempts: [], individuallyAdded: [], failedAdds: [], deleted: [], deletedDatabases: [], claimed: false, skipped: false, fetches: [], puts: [], migrated: [], activationOrder: [], oldCachedAssets: {{}}, sharedRecords: {{}}, todayCommands: {{}}, failTodayCommandPut: false, failSharedPut: false, backgroundFlushes: 0, backgroundResumes: 0, outboxPurges: 0, outboxLifecycle: [], notifications: [], closedNotifications: 0, focused: [], opened: [], appBadges: [], clearedAppBadges: 0, badgeEnabled: false, badgeCounts: {{updates:0, following:0, 'human-gates':0}}, failFetch: false, stallFetch: false, lateFetch: false, fetchAborted: false, fetchStatus: 200, fetchRedirected: false, cachedBody: null }};
|
||||
const storedResponses = new Map();
|
||||
storedResponses.set(
|
||||
'https://forge.example/dashboard/__offline-session-lease',
|
||||
@@ -83,7 +83,7 @@ const context = {{
|
||||
set: async enabled => {{ state.badgeEnabled = enabled; }},
|
||||
getCounts: async () => ({{...state.badgeCounts}}),
|
||||
setCount: async (channel, count) => {{ state.badgeCounts[channel] = count; }},
|
||||
clearCounts: async () => {{ state.badgeCounts = {{updates:0, following:0}}; }},
|
||||
clearCounts: async () => {{ state.badgeCounts = {{updates:0, following:0, 'human-gates':0}}; }},
|
||||
}},
|
||||
addEventListener: (name, handler) => {{ listeners[name] = handler; }},
|
||||
skipWaiting: async () => {{ state.skipped = true; }},
|
||||
@@ -935,6 +935,35 @@ def test_inbound_push_renders_generic_update_and_tap_opens_existing_workflow():
|
||||
assert "must-not-render" not in json.dumps(result["notifications"])
|
||||
|
||||
|
||||
def test_human_gate_push_is_count_only_and_tap_opens_canonical_mobile_review_route():
|
||||
result = run_worker_scenario(
|
||||
"""
|
||||
state.badgeEnabled = true;
|
||||
await dispatchPush({
|
||||
title:'must-not-render', body:'secret candidate must-not-render',
|
||||
tag:'stackchain-human-gates-2', route:'#/my-work/human-gates',
|
||||
human_gate_count:2, artifact_url:'https://secret.example/token',
|
||||
});
|
||||
await dispatchNotificationClick('#/my-work/human-gates', '', null, 'stackchain-human-gates-2');
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result["notifications"] == [{
|
||||
"title": "2 release decisions are waiting",
|
||||
"options": {
|
||||
"body": "Open Human Gates to review them.",
|
||||
"tag": "stackchain-human-gates-2",
|
||||
"data": {"route": "#/my-work/human-gates"},
|
||||
},
|
||||
}]
|
||||
assert result["opened"] == [
|
||||
"https://forge.example/dashboard/#/my-work/human-gates"
|
||||
]
|
||||
assert result["appBadges"] == [2]
|
||||
assert "secret" not in json.dumps(result["notifications"])
|
||||
|
||||
|
||||
def test_start_day_push_is_private_and_prepare_action_opens_cached_launch_route():
|
||||
result = run_worker_scenario(
|
||||
"""
|
||||
@@ -1023,7 +1052,7 @@ def test_background_badge_combines_following_and_updates_without_channel_overwri
|
||||
)
|
||||
|
||||
assert result["appBadges"] == [2, 5, 4]
|
||||
assert result["badgeCounts"] == {"updates": 3, "following": 1}
|
||||
assert result["badgeCounts"] == {"updates": 3, "following": 1, "human-gates": 0}
|
||||
assert result["clearedAppBadges"] == 0
|
||||
|
||||
|
||||
@@ -1039,7 +1068,7 @@ def test_worker_accepts_only_authenticated_authoritative_badge_channel_counts():
|
||||
"""
|
||||
)
|
||||
|
||||
assert result["badgeCounts"] == {"updates": 3, "following": 2}
|
||||
assert result["badgeCounts"] == {"updates": 3, "following": 2, "human-gates": 0}
|
||||
assert result["appBadges"] == [5]
|
||||
|
||||
|
||||
@@ -1054,7 +1083,7 @@ def test_foreground_channel_sync_invalidates_worker_render_cache_for_next_push()
|
||||
"""
|
||||
)
|
||||
|
||||
assert result["badgeCounts"] == {"updates": 2, "following": 0}
|
||||
assert result["badgeCounts"] == {"updates": 2, "following": 0, "human-gates": 0}
|
||||
assert result["appBadges"] == [2, 2]
|
||||
|
||||
|
||||
|
||||
@@ -209,6 +209,7 @@ async def test_push_poll_applies_configured_concurrency_to_start_day(
|
||||
monkeypatch.setattr(main.asyncio, "sleep", no_wait)
|
||||
monkeypatch.setattr(main, "dispatch_unread_updates", hold_dispatch)
|
||||
monkeypatch.setattr(main, "dispatch_following_changes", hold_dispatch)
|
||||
monkeypatch.setattr(main, "dispatch_human_gate_changes", hold_dispatch)
|
||||
monkeypatch.setattr(main, "dispatch_deadline_reminders", hold_dispatch)
|
||||
monkeypatch.setattr(main, "dispatch_start_day_reminders", capture_start_day)
|
||||
|
||||
|
||||
Reference in new issue
Block a user