Cover the 1:1 ring and huddle WS handlers (LC-614) #573

Merged
longjacksonle merged 1 commit from test/lc614-call-huddle-coverage into main 2026-07-21 02:28:08 +02:00

Closes LC-614. The prerequisite for LC-613 (converge call.js/voice.js) and LC-615 (the SFU handover).

Why now

The ringing slot, relay_call_signal, and handle_voice_join/handle_voice_leave had no direct tests - only indirect exercise through the transcription and ring suites. LC-596 AC4 requires the DM 1:1 path to keep its behaviour while LC-613 and LC-615 refactor exactly this code. Refactoring untested real-time signaling with a correctness requirement on it is the wrong order; this lands the net first.

How the internals are reached

relay_call_signal, handle_voice_join, and handle_voice_leave live in the WS receive loop. I did not build a WebSocket upgrade + framing harness - that would test axum's plumbing more than this logic, and would be flakier. Instead they are re-exposed through a labelled routes::test_support module, using the same pub use ws::... pattern the crate already uses for render_new_message_or_bump. The router never routes there; the only production edit is widening three fns from private to pub.

call_ring.rs

  • the ringing slot: vacant -> Started; same caller again -> DuplicateSelf; clear releases it; glare resolves to the first caller, and the winner's own payload is replayed to the loser, not the loser's
  • the relay: an invite reaches the peer only and posts exactly one "started a call" message; a duplicate invite neither re-rings nor re-posts
  • reject / cancel: post their system message and release the slot
  • refusals: outsiders, non-DM rooms, oversize payloads (> 64 KiB), and unknown kinds are all dropped - and none of them claims the slot (checked with a probe invite afterward)
  • blocks: a block in either direction kills signaling and claims no slot

huddle.rs

  • join registers the user, sends the joiner a VoiceRoster, and announces VoiceJoined to existing participants
  • join is refused for a room the user cannot access (a non-admin on an enclave-less private room; a site admin would pass, which is exactly why the test uses bob, not alice)
  • one user on two connections counts once - the dedup the SFU token gate in #569 had to work around
  • leave announces VoiceLeft and removes the user; leave on a never-registered connection is a no-op that disturbs nobody

Proven non-vacuous

Every assertion that matters was shown failing against a deliberate break before it counted:

  • disabling the block check -> a_block_in_either_direction_kills_signaling fails
  • making the occupied-slot branch return Started instead of Glare -> ringing_slot_resolves_glare_to_the_first_caller fails
  • dropping the access check in handle_voice_join -> join_is_refused_for_a_room_the_user_cannot_access fails

Each break failed only its own test.

Not covered, on purpose

  • The 60-second ringing TTL eviction: the slot's age is set from Instant::now() with no injectable clock, so testing it means either waiting 60s or adding a time seam. Not worth either here; flagged for whoever touches the slot next.
  • The empty-room transcript finalize on the last leave: already covered in transcripts.rs.

Full suite green (184 test binaries, up from 182), just check clean.

🤖 Generated with Claude Code

https://claude.ai/code/session_01FhDVMViNHqUbwmfe2aXTv5

Closes LC-614. The prerequisite for LC-613 (converge call.js/voice.js) and LC-615 (the SFU handover). ## Why now The ringing slot, `relay_call_signal`, and `handle_voice_join`/`handle_voice_leave` had no direct tests - only indirect exercise through the transcription and ring suites. LC-596 AC4 requires the DM 1:1 path to keep its behaviour while LC-613 and LC-615 refactor exactly this code. Refactoring untested real-time signaling with a correctness requirement on it is the wrong order; this lands the net first. ## How the internals are reached `relay_call_signal`, `handle_voice_join`, and `handle_voice_leave` live in the WS receive loop. I did not build a WebSocket upgrade + framing harness - that would test axum's plumbing more than this logic, and would be flakier. Instead they are re-exposed through a labelled `routes::test_support` module, using the same `pub use ws::...` pattern the crate already uses for `render_new_message_or_bump`. The router never routes there; the only production edit is widening three fns from private to `pub`. ## `call_ring.rs` - **the ringing slot**: vacant -> `Started`; same caller again -> `DuplicateSelf`; clear releases it; **glare resolves to the first caller**, and the winner's own payload is replayed to the loser, not the loser's - **the relay**: an invite reaches the peer only and posts exactly one "started a call" message; a duplicate invite neither re-rings nor re-posts - **reject / cancel**: post their system message and release the slot - **refusals**: outsiders, non-DM rooms, oversize payloads (> 64 KiB), and unknown kinds are all dropped - and none of them claims the slot (checked with a probe invite afterward) - **blocks**: a block in either direction kills signaling and claims no slot ## `huddle.rs` - join registers the user, sends the joiner a `VoiceRoster`, and announces `VoiceJoined` to existing participants - join is refused for a room the user cannot access (a non-admin on an enclave-less private room; a site admin would pass, which is exactly why the test uses bob, not alice) - one user on two connections counts once - the dedup the SFU token gate in #569 had to work around - leave announces `VoiceLeft` and removes the user; leave on a never-registered connection is a no-op that disturbs nobody ## Proven non-vacuous Every assertion that matters was shown failing against a deliberate break before it counted: - disabling the block check -> `a_block_in_either_direction_kills_signaling` fails - making the occupied-slot branch return `Started` instead of `Glare` -> `ringing_slot_resolves_glare_to_the_first_caller` fails - dropping the access check in `handle_voice_join` -> `join_is_refused_for_a_room_the_user_cannot_access` fails Each break failed only its own test. ## Not covered, on purpose - The 60-second ringing TTL eviction: the slot's age is set from `Instant::now()` with no injectable clock, so testing it means either waiting 60s or adding a time seam. Not worth either here; flagged for whoever touches the slot next. - The empty-room transcript finalize on the last leave: already covered in `transcripts.rs`. Full suite green (184 test binaries, up from 182), `just check` clean. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01FhDVMViNHqUbwmfe2aXTv5
test(call): cover the 1:1 ring and huddle WS handlers (LC-614)
All checks were successful
check-secrets / Nosey parker (push) Successful in 6s
check-secrets / TruffleHog (push) Successful in 9s
check-secrets / Kingfisher (push) Successful in 5s
check-secrets / Kingfisher (pull_request) Successful in 5s
check-secrets / TruffleHog (pull_request) Successful in 8s
check-secrets / Nosey parker (pull_request) Successful in 3s
Check / clippy + fmt + tests (pull_request) Successful in 5m21s
Create release / Create release from merged PR (pull_request) Has been skipped
82b46e0819
Prerequisite for LC-613 (converge call.js/voice.js) and LC-615 (the SFU handover). The ringing slot, `relay_call_signal`, and `handle_voice_join`/`handle_voice_leave` had no direct tests, yet LC-596 AC4 requires the DM 1:1 path to keep its behaviour while those tickets refactor exactly this code. This lands the net before the refactor.

`relay_call_signal`, `handle_voice_join`, and `handle_voice_leave` are internal to the WS receive loop. Rather than stand up a full WebSocket upgrade + framing harness - which would test axum's plumbing more than this logic - they are re-exposed through a labelled `routes::test_support` module (the same `pub use ws::...` pattern the crate already uses for `render_new_message_or_bump`). The router never routes there.

New `server/tests/call_ring.rs`:
- the ringing slot: a vacant slot is claimed (Started), the same caller re-inviting is DuplicateSelf, clearing releases it, and glare resolves to the FIRST caller with the winner's own payload replayed (not the loser's)
- the relay delivers an invite to the peer only and posts exactly one "started a call" message; a duplicate invite neither re-rings nor re-posts
- reject/cancel post their system message and release the slot
- outsiders, non-DM rooms, oversize payloads (> 64 KiB), and unknown kinds are all refused, and none of them claims the slot
- a block in either direction kills signaling and claims no slot

New `server/tests/huddle.rs`:
- join registers the user in the roster, sends the joiner a VoiceRoster, and announces VoiceJoined to existing participants
- join is refused for a room the user cannot access (a non-admin on a private room; note a site admin would pass, which is why the test uses bob)
- one user on two connections counts once (the dedup the SFU token gate had to work around)
- leave announces VoiceLeft and removes the user; leave on a never-registered connection is a no-op

Every test was shown failing against a deliberate break before it counted: disabling the block check fails the block test, making glare return Started fails the glare test, and dropping the voice-join access check fails the refusal test. Each break failed only its own test.

No production behaviour change; the only source edit is widening three fns from private to `pub` for the test surface. Not covered, and called out rather than faked: the 60s ringing TTL eviction (needs injectable time, not worth a 60s test) and the empty-room transcript finalize (already covered in transcripts.rs).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FhDVMViNHqUbwmfe2aXTv5
longjacksonle scheduled this pull request to auto merge when all checks succeed 2026-07-21 02:26:33 +02:00
longjacksonle deleted branch test/lc614-call-huddle-coverage 2026-07-21 02:28:09 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
psa-systems/lets-chat!573
No description provided.