Prd/components/backend/test/test_commands.rb
Lucy Doupalů be9f14ce34 Helpdesk - operator console + patched GrapheneOS Dialer for call handling
A small helpdesk system: an office Pixel running a patched GrapheneOS Dialer
answers technician calls, records both call legs as separate channels, and a
Ruby backend transcribes them through Whisper and files an AI summary against
the caller.

Squashed to a single commit for sharing. No credentials are included; secrets
live outside the repo in /etc/helpdesk/env on the server or a gitignored
.claude/env.local locally. See .claude/env.local.example for the shape.

Start at README.md, then docs/architecture.md.
2026-07-27 18:50:32 +02:00

138 lines
6.4 KiB
Ruby

# frozen_string_literal: true
# Zero-dependency tests for the reverse-channel command protocol (PC -> device call control).
# Run: ruby components/backend/test/test_commands.rb
require_relative "../lib/helpdesk/domain"
require_relative "../seed"
$pass = 0; $fail = 0
def ok(desc); $pass += 1; puts " ok #{desc}"; end
def bad(desc, got = nil); $fail += 1; puts " FAIL #{desc}#{got.nil? ? '' : " (got: #{got.inspect})"}"; end
def eq(a, b, desc); a == b ? ok(desc) : bad(desc, a); end
def truthy(x, desc); x ? ok(desc) : bad(desc, x); end
NOW = Time.utc(2026, 7, 2, 9, 0, 0)
CID = "22222222-2222-4222-8222-222222222222"
# a service with one ringing call on device "dev1"
def ringing(device: "dev1")
s = Helpdesk::Seed.call(Helpdesk::Service.new(Helpdesk::Store.new, clock: -> { NOW }))
s.incoming(call_id: CID, ts: NOW.iso8601, number: "601 234 567",
presentation: "allowed", dialed_did: "+420800111222", device_id: device)
s
end
def cmds(s, dev = "dev1") = s.store.commands[dev]
puts "issue_command enqueues for the call's device"
s = ringing
r = s.issue_command(call_id: CID, verb: "answer")
eq(r[:verb], "answer", "returns the queued verb")
eq(r[:queued_for], "dev1", "queued for the call's device")
eq(cmds(s).size, 1, "one command pending on dev1")
truthy(cmds(s).first[:id], "command carries an idempotency id")
puts "validation"
eq(s.issue_command(call_id: "nope", verb: "answer"), nil, "unknown call -> nil")
eq(s.issue_command(call_id: CID, verb: "explode")[:error], "unknown_verb", "unknown verb -> error")
eq(s.issue_command(call_id: CID, verb: "dtmf", arg: "X")[:error], "bad_arg", "bad dtmf arg -> error")
truthy(s.issue_command(call_id: CID, verb: "dtmf", arg: "5"), "valid dtmf arg accepted")
eq(cmds(s).select { |c| c[:verb] == "explode" }.size, 0, "invalid verb never enqueued")
puts "drain returns then clears, and is per-device"
s = ringing
s.issue_command(call_id: CID, verb: "answer")
drained = s.drain_commands("dev1")
eq(drained.size, 1, "drain returns pending")
eq(s.drain_commands("dev1").size, 0, "second drain is empty (cleared)")
s.issue_command(call_id: CID, verb: "answer")
eq(s.drain_commands("other-device").size, 0, "a different device drains nothing")
puts "apply: answer/hangup/reject drive the real state machine"
s = ringing
s.issue_command(call_id: CID, verb: "answer")
s.drain_commands("dev1").each { |c| s.apply_command(c) }
eq(s.store.event_by_call_id(CID)[:state], "answered", "answer command -> answered")
s = ringing
s.issue_command(call_id: CID, verb: "hangup")
res = s.apply_command(s.drain_commands("dev1").first)
eq(s.store.event_by_call_id(CID)[:state], "ended", "hangup command -> ended")
truthy(res[:applied], "apply result marks applied")
s = ringing
s.issue_command(call_id: CID, verb: "reject")
s.apply_command(s.drain_commands("dev1").first)
ev = s.store.event_by_call_id(CID)
eq(ev[:state], "ended", "reject command -> ended")
eq(ev[:disconnect_cause], "rejected", "reject sets disconnect_cause=rejected")
puts "apply: live toggles set attributes reflected in event_view"
s = ringing
s.apply_command({ id: "x", call_id: CID, verb: "answer" })
s.apply_command({ id: "x", call_id: CID, verb: "mute" })
eq(s.event_view(s.store.event_by_call_id(CID))[:muted], true, "mute -> muted:true in view")
s.apply_command({ id: "x", call_id: CID, verb: "unmute" })
eq(s.event_view(s.store.event_by_call_id(CID))[:muted], false, "unmute -> muted:false")
s.apply_command({ id: "x", call_id: CID, verb: "hold" })
eq(s.event_view(s.store.event_by_call_id(CID))[:on_hold], true, "hold -> on_hold:true")
s.apply_command({ id: "x", call_id: CID, verb: "resume" })
eq(s.event_view(s.store.event_by_call_id(CID))[:on_hold], false, "resume -> on_hold:false")
s.apply_command({ id: "x", call_id: CID, verb: "route", arg: "bluetooth" })
eq(s.event_view(s.store.event_by_call_id(CID))[:audio_route], "bluetooth", "route -> audio_route set")
s.apply_command({ id: "x", call_id: CID, verb: "dtmf", arg: "7" })
eq(s.store.event_by_call_id(CID)[:dtmf_log], ["7"], "dtmf appended to dtmf_log")
puts "idempotency / monotonic guard"
s = ringing
2.times { s.apply_command({ id: "x", call_id: CID, verb: "answer" }) }
eq(s.store.event_by_call_id(CID)[:state], "answered", "double answer stays answered (idempotent)")
s.apply_command({ id: "x", call_id: CID, verb: "hangup" })
s.apply_command({ id: "x", call_id: CID, verb: "answer" }) # late answer after ended
eq(s.store.event_by_call_id(CID)[:state], "ended", "answer after ended is ignored (monotonic)")
puts "long-poll: drain blocks then returns the INSTANT a command is issued (~1 RTT, not the full wait)"
s = ringing
got = nil; elapsed = nil
mono = -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
th = Thread.new do
t0 = mono.call
got = s.drain_commands("dev1", wait_s: 5) # blocks up to 5s
elapsed = mono.call - t0
end
sleep 0.15 # let the drain reach @cmd_cond.wait
s.issue_command(call_id: CID, verb: "mute") # another thread enqueues -> must wake the drain
th.join(3)
truthy(got && got.length == 1, "long-poll returned the enqueued command")
eq(got && got.first[:verb], "mute", "delivered the mute command")
truthy(elapsed && elapsed < 2.0, "woke fast, not after the full 5s wait (#{elapsed && elapsed.round(3)}s)")
puts "long-poll: returns [] on timeout when no command arrives"
s = ringing
t0 = mono.call
got = s.drain_commands("dev1", wait_s: 1)
dt = mono.call - t0
eq(got, [], "empty result after the wait")
truthy(dt >= 0.9 && dt < 2.5, "waited ~the full 1s timeout (#{dt.round(3)}s)")
puts "long-poll: wait_s 0 (default) stays an instant plain drain (dev endpoints + tests)"
s = ringing
s.issue_command(call_id: CID, verb: "answer")
t0 = mono.call
got = s.drain_commands("dev1")
dt = mono.call - t0
eq(got.length, 1, "plain drain returns the queued command")
truthy(dt < 0.2, "plain drain is instant (#{dt.round(3)}s)")
puts "device presence: the command poll is the heartbeat for 'waiting for the phone to connect'"
s = ringing
st = s.device_status
eq(st[:connected], false, "no poll yet -> not connected (console shows 'waiting')")
eq(st[:devices], [], "no devices seen")
s.drain_commands("dev1") # a poll from the phone
st = s.device_status
eq(st[:connected], true, "after a poll -> connected")
truthy(st[:devices].any? { |d| d[:device_id] == "dev1" && d[:connected] }, "dev1 present with fresh last-seen")
truthy(st[:devices].first[:last_seen_ago_s] < 5, "last-seen is recent")
puts "\n#{$pass} passed, #{$fail} failed"
exit($fail.zero? ? 0 : 1)