Add UserId newtype and WebSocket ping keep-alive
All checks were successful
CI / ci (push) Successful in 5m57s

Replace raw String user IDs with a UserId newtype in domain for
type safety, and add 30s ping/pong to prevent idle WebSocket
disconnects.
This commit is contained in:
2026-08-18 17:02:19 +02:00
parent 4db04329b4
commit 99f614b7b7
11 changed files with 100 additions and 61 deletions

View File

@@ -1,14 +1,18 @@
mod common;
use application::canvas::place_pixel::{Command, Outcome};
use domain::{BroadcastEvent, Color, Position};
use domain::{BroadcastEvent, Color, Position, UserId};
fn uid(id: &str) -> UserId {
UserId::new(id.to_string())
}
macro_rules! place {
($state:expr, $user:expr, $x:expr, $y:expr, $color:expr) => {
application::canvas::place_pixel::execute(
&$state,
Command {
user_id: $user,
user_id: &uid($user),
position: Position::new($x, $y),
color: Color::new($color),
},

View File

@@ -3,7 +3,7 @@ mod common;
use std::sync::Arc;
use application::AppState;
use domain::Color;
use domain::{Color, UserId};
fn state_with_persistence(persistence: common::FakePersistence) -> Arc<AppState> {
let spy = common::SpyBroadcaster::new();
@@ -22,10 +22,11 @@ fn save_snapshot_persists_current_canvas() {
let saved_ref = persistence.saved_ref();
let state = state_with_persistence(persistence);
let user = UserId::new("user".to_string());
application::canvas::place_pixel::execute(
&state,
application::canvas::place_pixel::Command {
user_id: "user",
user_id: &user,
position: domain::Position::new(0, 0),
color: Color::new(0xFF),
},

View File

@@ -1,40 +1,41 @@
mod common;
use domain::BroadcastEvent;
use domain::{BroadcastEvent, UserId};
fn uid(id: &str) -> UserId {
UserId::new(id.to_string())
}
#[test]
fn connect_increments_count() {
let (state, _) = common::test_state();
assert_eq!(
application::soldiers::connect::execute(&state, "a".into()),
1
);
assert_eq!(
application::soldiers::connect::execute(&state, "b".into()),
2
);
assert_eq!(
application::soldiers::connect::execute(&state, "c".into()),
3
);
assert_eq!(application::soldiers::connect::execute(&state, uid("a")), 1);
assert_eq!(application::soldiers::connect::execute(&state, uid("b")), 2);
assert_eq!(application::soldiers::connect::execute(&state, uid("c")), 3);
}
#[test]
fn disconnect_decrements_count() {
let (state, _) = common::test_state();
application::soldiers::connect::execute(&state, "a".into());
application::soldiers::connect::execute(&state, "b".into());
application::soldiers::connect::execute(&state, uid("a"));
application::soldiers::connect::execute(&state, uid("b"));
assert_eq!(application::soldiers::disconnect::execute(&state, "a"), 1);
assert_eq!(application::soldiers::disconnect::execute(&state, "b"), 0);
assert_eq!(
application::soldiers::disconnect::execute(&state, &uid("a")),
1
);
assert_eq!(
application::soldiers::disconnect::execute(&state, &uid("b")),
0
);
}
#[test]
fn disconnect_unknown_user_is_harmless() {
let (state, _) = common::test_state();
application::soldiers::connect::execute(&state, "a".into());
application::soldiers::connect::execute(&state, uid("a"));
assert_eq!(
application::soldiers::disconnect::execute(&state, "unknown"),
application::soldiers::disconnect::execute(&state, &uid("unknown")),
1
);
}
@@ -42,7 +43,7 @@ fn disconnect_unknown_user_is_harmless() {
#[test]
fn connect_publishes_soldier_count() {
let (state, spy) = common::test_state();
application::soldiers::connect::execute(&state, "a".into());
application::soldiers::connect::execute(&state, uid("a"));
let events = spy.events();
assert_eq!(events.len(), 1);
@@ -52,8 +53,8 @@ fn connect_publishes_soldier_count() {
#[test]
fn disconnect_publishes_soldier_count() {
let (state, spy) = common::test_state();
application::soldiers::connect::execute(&state, "a".into());
application::soldiers::disconnect::execute(&state, "a");
application::soldiers::connect::execute(&state, uid("a"));
application::soldiers::disconnect::execute(&state, &uid("a"));
let events = spy.events();
assert_eq!(events.len(), 2);
@@ -63,26 +64,27 @@ fn disconnect_publishes_soldier_count() {
#[test]
fn disconnect_clears_cooldown() {
let (state, _) = common::test_state();
application::soldiers::connect::execute(&state, "user-1".into());
let user = uid("user-1");
application::soldiers::connect::execute(&state, user.clone());
application::canvas::place_pixel::execute(
&state,
application::canvas::place_pixel::Command {
user_id: "user-1",
user_id: &user,
position: domain::Position::new(0, 0),
color: domain::Color::new(0xFF),
},
)
.unwrap();
application::soldiers::disconnect::execute(&state, "user-1");
application::soldiers::disconnect::execute(&state, &user);
// Reconnect with same ID — cooldown should be gone
application::soldiers::connect::execute(&state, "user-1".into());
application::soldiers::connect::execute(&state, user.clone());
let result = application::canvas::place_pixel::execute(
&state,
application::canvas::place_pixel::Command {
user_id: "user-1",
user_id: &user,
position: domain::Position::new(1, 1),
color: domain::Color::new(0xAA),
},