Add UserId newtype and WebSocket ping keep-alive
All checks were successful
CI / ci (push) Successful in 5m57s
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:
@@ -1,9 +1,9 @@
|
||||
use domain::{BroadcastEvent, Color, PixelUpdate, Position};
|
||||
use domain::{BroadcastEvent, Color, PixelUpdate, Position, UserId};
|
||||
|
||||
use crate::{AppState, ApplicationError};
|
||||
|
||||
pub struct Command<'a> {
|
||||
pub user_id: &'a str,
|
||||
pub user_id: &'a UserId,
|
||||
pub position: Position,
|
||||
pub color: Color,
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
use domain::BroadcastEvent;
|
||||
use domain::{BroadcastEvent, UserId};
|
||||
|
||||
use crate::AppState;
|
||||
|
||||
pub fn execute(state: &AppState, user_id: String) -> usize {
|
||||
pub fn execute(state: &AppState, user_id: UserId) -> usize {
|
||||
let count = state.soldiers().add(user_id);
|
||||
state
|
||||
.broadcaster()
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
use domain::BroadcastEvent;
|
||||
use domain::{BroadcastEvent, UserId};
|
||||
|
||||
use crate::AppState;
|
||||
|
||||
pub fn execute(state: &AppState, user_id: &str) -> usize {
|
||||
pub fn execute(state: &AppState, user_id: &UserId) -> usize {
|
||||
state.cooldowns().remove(user_id);
|
||||
let count = state.soldiers().remove(user_id);
|
||||
state
|
||||
|
||||
@@ -7,7 +7,7 @@ use std::time::{Duration, Instant};
|
||||
use domain::ports::{
|
||||
BroadcastReceiverInner, BroadcastSubscription, CanvasPersistence, CanvasStore, EventBroadcaster,
|
||||
};
|
||||
use domain::{BroadcastEvent, Canvas, Color, DomainError, Position};
|
||||
use domain::{BroadcastEvent, Canvas, Color, DomainError, Position, UserId};
|
||||
use tokio::sync::broadcast;
|
||||
use tracing::warn;
|
||||
|
||||
@@ -145,7 +145,7 @@ impl CanvasStore for InMemoryCanvasStore {
|
||||
}
|
||||
|
||||
pub struct CooldownTracker {
|
||||
entries: Mutex<HashMap<String, Instant>>,
|
||||
entries: Mutex<HashMap<UserId, Instant>>,
|
||||
cooldown: Duration,
|
||||
}
|
||||
|
||||
@@ -157,7 +157,7 @@ impl CooldownTracker {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_on_cooldown(&self, user_id: &str) -> bool {
|
||||
pub fn is_on_cooldown(&self, user_id: &UserId) -> bool {
|
||||
let entries = acquire_lock(&self.entries);
|
||||
entries
|
||||
.get(user_id)
|
||||
@@ -165,17 +165,17 @@ impl CooldownTracker {
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
pub fn record(&self, user_id: &str) {
|
||||
acquire_lock(&self.entries).insert(user_id.to_string(), Instant::now());
|
||||
pub fn record(&self, user_id: &UserId) {
|
||||
acquire_lock(&self.entries).insert(user_id.clone(), Instant::now());
|
||||
}
|
||||
|
||||
pub fn remove(&self, user_id: &str) {
|
||||
pub fn remove(&self, user_id: &UserId) {
|
||||
acquire_lock(&self.entries).remove(user_id);
|
||||
}
|
||||
}
|
||||
|
||||
pub struct SoldierTracker {
|
||||
connected: Mutex<HashSet<String>>,
|
||||
connected: Mutex<HashSet<UserId>>,
|
||||
}
|
||||
|
||||
impl SoldierTracker {
|
||||
@@ -185,13 +185,13 @@ impl SoldierTracker {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn add(&self, user_id: String) -> usize {
|
||||
pub fn add(&self, user_id: UserId) -> usize {
|
||||
let mut connected = acquire_lock(&self.connected);
|
||||
connected.insert(user_id);
|
||||
connected.len()
|
||||
}
|
||||
|
||||
pub fn remove(&self, user_id: &str) -> usize {
|
||||
pub fn remove(&self, user_id: &UserId) -> usize {
|
||||
let mut connected = acquire_lock(&self.connected);
|
||||
connected.remove(user_id);
|
||||
connected.len()
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user