refactor(client): separate accepted negotiation state from peer evidence
This commit is contained in:
32 files changed
+507
-447
No files matched your search
@@ -52,25 +52,24 @@ async fn exercise_client_server() {
|
||||
|
||||
let opened = client.open().await.unwrap();
|
||||
match &opened {
|
||||
OpenOutcome::Started {
|
||||
OpenOutcome::Started(irtt_client::SessionStarted {
|
||||
remote,
|
||||
token,
|
||||
negotiated,
|
||||
event,
|
||||
} => {
|
||||
negotiation: negotiated,
|
||||
..
|
||||
}) => {
|
||||
assert_eq!(*remote, server_addr);
|
||||
assert_ne!(*token, 0);
|
||||
assert_eq!(negotiated.params.received_stats, ReceivedStats::Both);
|
||||
assert_eq!(negotiated.params.stamp_at, StampAt::Both);
|
||||
assert_eq!(negotiated.params.clock, Clock::Both);
|
||||
assert_eq!(negotiated.accepted.received_stats, ReceivedStats::Both);
|
||||
assert_eq!(negotiated.accepted.stamp_at, StampAt::Both);
|
||||
assert_eq!(negotiated.accepted.clock, Clock::Both);
|
||||
// A default client requests no fill, and this open succeeded under
|
||||
// the default strict negotiation policy — which it could not have
|
||||
// if the server had answered an absent request with its own default
|
||||
// descriptor. The server still fills the payload with it.
|
||||
assert_eq!(negotiated.params.server_fill, None);
|
||||
assert!(matches!(event, ClientEvent::SessionStarted { .. }));
|
||||
assert_eq!(negotiated.accepted.server_fill, None);
|
||||
}
|
||||
OpenOutcome::NoTestCompleted { .. } => panic!("normal client unexpectedly ran no-test"),
|
||||
OpenOutcome::NoTestCompleted(_) => panic!("normal client unexpectedly ran no-test"),
|
||||
}
|
||||
|
||||
assert!(matches!(
|
||||
|
||||
@@ -327,7 +327,7 @@ fn ignored_open_traffic_cannot_extend_the_absolute_attempt_deadline() {
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
client.open().await.unwrap(),
|
||||
OpenOutcome::Started { .. }
|
||||
OpenOutcome::Started(_)
|
||||
));
|
||||
});
|
||||
assert_eq!(server.finish().len(), 2);
|
||||
@@ -404,7 +404,7 @@ fn authenticated_async_lifecycle_negotiates_dscp() {
|
||||
.await
|
||||
.unwrap();
|
||||
let opened = client.open().await.unwrap();
|
||||
assert_eq!(open_negotiated(&opened).params.dscp, 184);
|
||||
assert_eq!(open_negotiated(&opened).accepted.dscp, 46);
|
||||
client.send_probe().await.unwrap();
|
||||
assert!(matches!(client.recv().await.unwrap().as_slice(), [ClientEvent::EchoReply { seq: 0, .. }]));
|
||||
assert!(matches!(client.close().await.unwrap().as_slice(), [ClientEvent::SessionClosed { token, .. }] if *token == open_token(&opened)));
|
||||
@@ -450,17 +450,23 @@ fn blocking_and_async_peer_close_are_semantically_equivalent() {
|
||||
}
|
||||
}
|
||||
|
||||
fn open_negotiated(outcome: &OpenOutcome) -> &irtt_client::NegotiatedParams {
|
||||
fn open_negotiated(outcome: &OpenOutcome) -> &irtt_client::NegotiationResult {
|
||||
match outcome {
|
||||
OpenOutcome::Started { negotiated, .. }
|
||||
| OpenOutcome::NoTestCompleted { negotiated, .. } => negotiated,
|
||||
OpenOutcome::Started(irtt_client::SessionStarted {
|
||||
negotiation: negotiated,
|
||||
..
|
||||
})
|
||||
| OpenOutcome::NoTestCompleted(irtt_client::NoTestCompleted {
|
||||
negotiation: negotiated,
|
||||
..
|
||||
}) => negotiated,
|
||||
}
|
||||
}
|
||||
|
||||
fn open_token(outcome: &OpenOutcome) -> u64 {
|
||||
match outcome {
|
||||
OpenOutcome::Started { token, .. } => *token,
|
||||
OpenOutcome::NoTestCompleted { .. } => panic!("no-test open has no session token"),
|
||||
OpenOutcome::Started(irtt_client::SessionStarted { token, .. }) => *token,
|
||||
OpenOutcome::NoTestCompleted(_) => panic!("no-test open has no session token"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -577,8 +583,8 @@ fn blocking_and_async_roll_back_a_failed_open_and_accept_a_retry() {
|
||||
// state that refuses the retry.
|
||||
assert_eq!(error_name(&blocking_first), "open timeout");
|
||||
assert_eq!(error_name(&blocking_first), error_name(&async_first));
|
||||
assert!(matches!(blocking_second, OpenOutcome::Started { .. }));
|
||||
assert!(matches!(async_second, OpenOutcome::Started { .. }));
|
||||
assert!(matches!(blocking_second, OpenOutcome::Started(_)));
|
||||
assert!(matches!(async_second, OpenOutcome::Started(_)));
|
||||
assert_eq!(
|
||||
open_negotiated(&blocking_second),
|
||||
open_negotiated(&async_second)
|
||||
@@ -610,10 +616,7 @@ fn blocking_and_async_complete_every_caller_paced_probe() {
|
||||
.set_recv_timeout(Some(Duration::from_millis(200)))
|
||||
.unwrap();
|
||||
let outcome = blocking.open().unwrap();
|
||||
assert_eq!(
|
||||
blocking.negotiated_params(),
|
||||
Some(open_negotiated(&outcome))
|
||||
);
|
||||
assert_eq!(blocking.negotiation(), Some(open_negotiated(&outcome)));
|
||||
assert!(!blocking.has_pending_probes());
|
||||
// Oversleeping cannot invalidate this: low-level sends remain caller-owned
|
||||
// after the negotiated duration. The compliant server has no duration cap.
|
||||
@@ -636,7 +639,7 @@ fn blocking_and_async_complete_every_caller_paced_probe() {
|
||||
.await
|
||||
.unwrap();
|
||||
let outcome = client.open().await.unwrap();
|
||||
assert_eq!(client.negotiated_params(), Some(open_negotiated(&outcome)));
|
||||
assert_eq!(client.negotiation(), Some(open_negotiated(&outcome)));
|
||||
tokio::time::sleep(Duration::from_millis(2)).await;
|
||||
let mut sent = Vec::new();
|
||||
let mut replies = Vec::new();
|
||||
|
||||
@@ -81,10 +81,16 @@ fn server_fill_negotiated_params_reflect_accepted_value() {
|
||||
.unwrap();
|
||||
let outcome = client.open().unwrap();
|
||||
let negotiated = match outcome {
|
||||
irtt_client::OpenOutcome::Started { negotiated, .. } => negotiated,
|
||||
irtt_client::OpenOutcome::Started(irtt_client::SessionStarted {
|
||||
negotiation: negotiated,
|
||||
..
|
||||
}) => negotiated,
|
||||
other => panic!("expected started outcome, got {other:?}"),
|
||||
};
|
||||
assert_eq!(negotiated.params.server_fill, params.server_fill);
|
||||
assert_eq!(
|
||||
negotiated.accepted.server_fill,
|
||||
params.server_fill.map(|fill| fill.value)
|
||||
);
|
||||
|
||||
client.send_probe().unwrap();
|
||||
assert!(matches!(
|
||||
@@ -144,7 +150,7 @@ fn server_fill_loose_allows_server_to_remove_fill() {
|
||||
open_observation_params(&run).server_fill,
|
||||
requested.server_fill
|
||||
);
|
||||
assert_eq!(run.negotiated.params.server_fill, None);
|
||||
assert_eq!(run.negotiated.accepted.server_fill, None);
|
||||
}
|
||||
|
||||
// ─── Echo behavior ───
|
||||
@@ -169,11 +175,11 @@ fn server_fill_does_not_affect_client_side_packet_layout() {
|
||||
let (echo_len_without, _, _, _) = echo_observation(&run_without);
|
||||
assert_eq!(echo_len_with, echo_len_without);
|
||||
assert_eq!(
|
||||
test_echo_packet_len(false, &run_with.negotiated.params),
|
||||
test_echo_packet_len(false, &run_with.negotiated.peer_params),
|
||||
echo_len_with
|
||||
);
|
||||
assert_eq!(
|
||||
test_echo_packet_len(false, &run_without.negotiated.params),
|
||||
test_echo_packet_len(false, &run_without.negotiated.peer_params),
|
||||
echo_len_without
|
||||
);
|
||||
}
|
||||
@@ -10,7 +10,7 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use irtt_client::{
|
||||
AsyncClient, ClientConfig, NegotiationPolicy, NegotiationRestriction, OpenOutcome, OpenPolicy,
|
||||
AsyncClient, ClientConfig, NegotiationChange, NegotiationPolicy, OpenOutcome, OpenPolicy,
|
||||
SessionRequest,
|
||||
};
|
||||
use irtt_proto::{
|
||||
@@ -166,21 +166,23 @@ async fn exercise_fallback_negotiation() {
|
||||
.await
|
||||
.unwrap();
|
||||
match loose.open().await.unwrap() {
|
||||
OpenOutcome::Started { negotiated, .. } => {
|
||||
OpenOutcome::Started(irtt_client::SessionStarted {
|
||||
negotiation: negotiated,
|
||||
..
|
||||
}) => {
|
||||
assert_eq!(
|
||||
negotiated
|
||||
.params
|
||||
.server_fill
|
||||
.map(|fill| fill.value)
|
||||
.as_deref(),
|
||||
negotiated.accepted.server_fill.as_deref(),
|
||||
Some("pattern:69727474"),
|
||||
"the server reports the default descriptor it fell back to"
|
||||
);
|
||||
assert!(negotiated
|
||||
.restrictions
|
||||
.contains(&NegotiationRestriction::ServerFillChanged));
|
||||
.changes
|
||||
.contains(&NegotiationChange::ServerFillChanged {
|
||||
requested: Some("bogus".to_owned()),
|
||||
negotiated: Some("pattern:69727474".to_owned())
|
||||
}));
|
||||
}
|
||||
OpenOutcome::NoTestCompleted { .. } => panic!("normal client unexpectedly ran no-test"),
|
||||
OpenOutcome::NoTestCompleted(_) => panic!("normal client unexpectedly ran no-test"),
|
||||
}
|
||||
loose.close().await.unwrap();
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ fn hmac_open_success_negotiates_without_warnings() {
|
||||
},
|
||||
);
|
||||
|
||||
assert_eq!(run.negotiated.params, params);
|
||||
assert_eq!(run.negotiated.peer_params, params);
|
||||
match &run.observations[0] {
|
||||
ServerObservation::Open { params: got, hmac } => {
|
||||
assert_eq!(got, ¶ms);
|
||||
@@ -249,21 +249,13 @@ fn non_hmac_client_open_does_not_set_hmac_flag() {
|
||||
|
||||
fn assert_started(outcome: OpenOutcome, expected: &irtt_proto::Params) {
|
||||
match outcome {
|
||||
OpenOutcome::Started {
|
||||
OpenOutcome::Started(irtt_client::SessionStarted {
|
||||
token,
|
||||
negotiated,
|
||||
event:
|
||||
ClientEvent::SessionStarted {
|
||||
token: event_token,
|
||||
negotiated: event_negotiated,
|
||||
..
|
||||
},
|
||||
negotiation: negotiated,
|
||||
..
|
||||
} => {
|
||||
}) => {
|
||||
assert_eq!(token, TOKEN);
|
||||
assert_eq!(event_token, TOKEN);
|
||||
assert_eq!(negotiated.params, *expected);
|
||||
assert_eq!(event_negotiated.params, *expected);
|
||||
assert_eq!(negotiated.peer_params, *expected);
|
||||
}
|
||||
other => panic!("expected started open outcome, got {other:?}"),
|
||||
}
|
||||
@@ -297,7 +289,7 @@ fn backend_hmac_correct_key_succeeds() {
|
||||
.set_recv_timeout(Some(Duration::from_millis(500)))
|
||||
.unwrap();
|
||||
let outcome = client.open().unwrap();
|
||||
assert!(matches!(outcome, OpenOutcome::Started { .. }));
|
||||
assert!(matches!(outcome, OpenOutcome::Started(_)));
|
||||
|
||||
let sent = client.send_probe().unwrap();
|
||||
assert_eq!(sent.len(), 1);
|
||||
|
||||
@@ -93,7 +93,7 @@ fn backend_large_packet_smoke() {
|
||||
.unwrap();
|
||||
|
||||
let outcome = client.open().unwrap();
|
||||
assert!(matches!(outcome, irtt_client::OpenOutcome::Started { .. }));
|
||||
assert!(matches!(outcome, irtt_client::OpenOutcome::Started(_)));
|
||||
|
||||
client.send_probe().unwrap();
|
||||
let events = client.recv_once().unwrap();
|
||||
|
||||
@@ -162,7 +162,7 @@ async fn target_updates_preserve_identical_sessions_and_retire_removed_generatio
|
||||
match events.recv().await.unwrap() {
|
||||
ManagedEvent::Client {
|
||||
target,
|
||||
event: ClientEvent::SessionStarted { .. },
|
||||
event: ClientEvent::SessionStarted(_),
|
||||
} if target == first => panic!("identical update reopened the session"),
|
||||
ManagedEvent::Client {
|
||||
target,
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use irtt_client::{
|
||||
AsyncClient, ClientConfig, NegotiationPolicy, NegotiationRestriction, OpenOutcome, OpenPolicy,
|
||||
AsyncClient, ClientConfig, NegotiationChange, NegotiationPolicy, OpenOutcome, OpenPolicy,
|
||||
SessionRequest,
|
||||
};
|
||||
use irtt_proto::{Clock, ReceivedStats, StampAt};
|
||||
@@ -40,22 +40,25 @@ async fn exercise_timestamp_restriction() {
|
||||
|
||||
let mut loose = client(server_addr, NegotiationPolicy::Loose).await;
|
||||
let negotiated = match loose.open().await.unwrap() {
|
||||
OpenOutcome::Started { negotiated, .. } => negotiated,
|
||||
OpenOutcome::NoTestCompleted { .. } => panic!("normal client unexpectedly ran no-test"),
|
||||
OpenOutcome::Started(irtt_client::SessionStarted {
|
||||
negotiation: negotiated,
|
||||
..
|
||||
}) => negotiated,
|
||||
OpenOutcome::NoTestCompleted(_) => panic!("normal client unexpectedly ran no-test"),
|
||||
};
|
||||
assert_eq!(
|
||||
negotiated.params.stamp_at,
|
||||
negotiated.accepted.stamp_at,
|
||||
StampAt::Midpoint,
|
||||
"a request for both instants is answered with the midpoint"
|
||||
);
|
||||
assert_eq!(
|
||||
negotiated.params.clock,
|
||||
negotiated.accepted.clock,
|
||||
Clock::Both,
|
||||
"the clock is not restricted with it"
|
||||
);
|
||||
assert!(negotiated
|
||||
.restrictions
|
||||
.contains(&NegotiationRestriction::StampAtChanged {
|
||||
.changes
|
||||
.contains(&NegotiationChange::StampAtChanged {
|
||||
requested: StampAt::Both,
|
||||
negotiated: StampAt::Midpoint,
|
||||
}));
|
||||
@@ -112,16 +115,19 @@ async fn exercise_dscp_restriction() {
|
||||
.await
|
||||
.unwrap();
|
||||
let negotiated = match loose.open().await.unwrap() {
|
||||
OpenOutcome::Started { negotiated, .. } => negotiated,
|
||||
OpenOutcome::NoTestCompleted { .. } => panic!("normal client unexpectedly ran no-test"),
|
||||
OpenOutcome::Started(irtt_client::SessionStarted {
|
||||
negotiation: negotiated,
|
||||
..
|
||||
}) => negotiated,
|
||||
OpenOutcome::NoTestCompleted(_) => panic!("normal client unexpectedly ran no-test"),
|
||||
};
|
||||
assert_eq!(
|
||||
negotiated.params.dscp, 0,
|
||||
"the raw wire parameter is negotiated to zero"
|
||||
negotiated.accepted.dscp, 0,
|
||||
"the accepted DSCP codepoint is zero"
|
||||
);
|
||||
assert!(negotiated
|
||||
.restrictions
|
||||
.contains(&NegotiationRestriction::DscpChanged {
|
||||
.changes
|
||||
.contains(&NegotiationChange::DscpChanged {
|
||||
requested: 46,
|
||||
negotiated: 0,
|
||||
}));
|
||||
@@ -159,14 +165,21 @@ async fn exercise_default_policy() {
|
||||
.await
|
||||
.unwrap();
|
||||
let negotiated = match client.open().await.unwrap() {
|
||||
OpenOutcome::Started { negotiated, .. } => negotiated,
|
||||
OpenOutcome::NoTestCompleted { .. } => panic!("normal client unexpectedly ran no-test"),
|
||||
OpenOutcome::Started(irtt_client::SessionStarted {
|
||||
negotiation: negotiated,
|
||||
..
|
||||
}) => negotiated,
|
||||
OpenOutcome::NoTestCompleted(_) => panic!("normal client unexpectedly ran no-test"),
|
||||
};
|
||||
|
||||
assert_eq!(negotiated.restrictions, Vec::new());
|
||||
assert_eq!(negotiated.params.stamp_at, StampAt::Both);
|
||||
assert_eq!(negotiated.params.clock, Clock::Both);
|
||||
assert_eq!(negotiated.params.dscp, 184, "the raw byte for codepoint 46");
|
||||
assert_eq!(negotiated.changes, Vec::new());
|
||||
assert_eq!(negotiated.accepted.stamp_at, StampAt::Both);
|
||||
assert_eq!(negotiated.accepted.clock, Clock::Both);
|
||||
assert_eq!(negotiated.accepted.dscp, 46);
|
||||
assert_eq!(
|
||||
negotiated.peer_params.dscp, 184,
|
||||
"the raw byte for codepoint 46"
|
||||
);
|
||||
|
||||
probe(&mut client).await;
|
||||
client.close().await.unwrap();
|
||||
|
||||
@@ -7,8 +7,8 @@ mod protocol_options {
|
||||
use std::time::Duration;
|
||||
|
||||
use irtt_client::{
|
||||
Client, ClientConfig, ClientError, ClientEvent, OneWayDelaySample, ReceivedStatsSample,
|
||||
RttSample, ServerTiming, SignedDuration,
|
||||
AcceptedSessionParameters, Client, ClientConfig, ClientError, ClientEvent, OneWayDelaySample,
|
||||
ReceivedStatsSample, RttSample, ServerTiming, SignedDuration,
|
||||
};
|
||||
use irtt_proto::{Clock, Params, ReceivedStats, StampAt, TimestampFields};
|
||||
|
||||
@@ -212,7 +212,20 @@ fn loose_open_uses_returned_params_for_echo_layout_and_reply_parsing() {
|
||||
});
|
||||
|
||||
assert_eq!(open_params(&run), &requested);
|
||||
assert_eq!(run.negotiated.params, returned);
|
||||
assert_eq!(run.negotiated.peer_params, returned);
|
||||
assert_eq!(
|
||||
run.negotiated.accepted,
|
||||
AcceptedSessionParameters {
|
||||
duration: Some(Duration::from_secs(3)),
|
||||
interval: Duration::from_secs(1),
|
||||
length: 28,
|
||||
received_stats: ReceivedStats::Count,
|
||||
stamp_at: StampAt::Receive,
|
||||
clock: Clock::Wall,
|
||||
dscp: 0,
|
||||
server_fill: None,
|
||||
}
|
||||
);
|
||||
assert_echo_uses_params(&run, &returned, false);
|
||||
|
||||
let reply = expect_echo_reply(&run.reply);
|
||||
@@ -253,7 +266,7 @@ fn changed_compatibility_params(requested: &Params) -> Vec<Params> {
|
||||
}
|
||||
|
||||
fn assert_negotiated_echo_use(run: &OneProbeRun, params: &Params, hmac: bool) {
|
||||
assert_eq!(run.negotiated.params, *params);
|
||||
assert_eq!(run.negotiated.peer_params, *params);
|
||||
assert_eq!(open_params(run), params);
|
||||
assert_echo_uses_params(run, params, hmac);
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ fn backend_basic_open_echo_close() {
|
||||
.unwrap();
|
||||
|
||||
let outcome = client.open().unwrap();
|
||||
assert!(matches!(outcome, irtt_client::OpenOutcome::Started { .. }));
|
||||
assert!(matches!(outcome, irtt_client::OpenOutcome::Started(_)));
|
||||
|
||||
let sent = client.send_probe().unwrap();
|
||||
assert_eq!(sent.len(), 1);
|
||||
|
||||
@@ -13,7 +13,7 @@ use std::{
|
||||
};
|
||||
|
||||
use irtt_client::{
|
||||
Client, ClientConfig, ClientEvent, NegotiatedParams, OpenOutcome, OpenPolicy, SessionRequest,
|
||||
Client, ClientConfig, ClientEvent, NegotiationResult, OpenOutcome, OpenPolicy, SessionRequest,
|
||||
};
|
||||
use irtt_proto::{
|
||||
compute_hmac_in_place, decode_request, echo_packet_len, encode_echo_reply, encode_open_reply,
|
||||
@@ -138,7 +138,7 @@ pub enum ServerObservation {
|
||||
}
|
||||
|
||||
pub struct OneProbeRun {
|
||||
pub negotiated: NegotiatedParams,
|
||||
pub negotiated: NegotiationResult,
|
||||
pub sent: ClientEvent,
|
||||
pub reply: ClientEvent,
|
||||
pub observations: Vec<ServerObservation>,
|
||||
@@ -514,10 +514,13 @@ fn recv_request_timeout(socket: &UdpSocket) -> Option<Vec<u8>> {
|
||||
.map(|(size, _)| buf[..size].to_vec())
|
||||
}
|
||||
|
||||
fn assert_started(outcome: OpenOutcome) -> NegotiatedParams {
|
||||
fn assert_started(outcome: OpenOutcome) -> NegotiationResult {
|
||||
match outcome {
|
||||
OpenOutcome::Started { negotiated, .. } => negotiated,
|
||||
OpenOutcome::NoTestCompleted { .. } => panic!("expected started outcome"),
|
||||
OpenOutcome::Started(irtt_client::SessionStarted {
|
||||
negotiation: negotiated,
|
||||
..
|
||||
}) => negotiated,
|
||||
OpenOutcome::NoTestCompleted(_) => panic!("expected started outcome"),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -217,12 +217,15 @@ async fn opened_session(server_addr: SocketAddr, dscp: u8) -> OpenSession {
|
||||
.await
|
||||
.unwrap();
|
||||
match client.open().await.unwrap() {
|
||||
OpenOutcome::Started { negotiated, .. } => assert_eq!(
|
||||
negotiated.params.dscp,
|
||||
OpenOutcome::Started(irtt_client::SessionStarted {
|
||||
negotiation: negotiated,
|
||||
..
|
||||
}) => assert_eq!(
|
||||
negotiated.peer_params.dscp,
|
||||
i64::from(dscp) << 2,
|
||||
"the server negotiates the raw byte the codepoint asks for"
|
||||
),
|
||||
OpenOutcome::NoTestCompleted { .. } => panic!("a normal client unexpectedly ran no-test"),
|
||||
OpenOutcome::NoTestCompleted(_) => panic!("a normal client unexpectedly ran no-test"),
|
||||
}
|
||||
OpenSession {
|
||||
client,
|
||||
|
||||
@@ -220,11 +220,11 @@ async fn connected_client(server_addr: SocketAddr) -> AsyncClient {
|
||||
|
||||
async fn assert_started(client: &mut AsyncClient, server_addr: SocketAddr) {
|
||||
match client.open().await.unwrap() {
|
||||
OpenOutcome::Started { remote, token, .. } => {
|
||||
OpenOutcome::Started(irtt_client::SessionStarted { remote, token, .. }) => {
|
||||
assert_eq!(remote, server_addr);
|
||||
assert_ne!(token, 0);
|
||||
}
|
||||
OpenOutcome::NoTestCompleted { .. } => panic!("a normal client unexpectedly ran no-test"),
|
||||
OpenOutcome::NoTestCompleted(_) => panic!("a normal client unexpectedly ran no-test"),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user