Skip to content

Commit 4f79bc8

Browse files
committed
feat(qbittorrent-e2e): verify tracker swarm participation via REST API after transfer
1 parent 9c11c91 commit 4f79bc8

11 files changed

Lines changed: 165 additions & 9 deletions

File tree

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ torrust-axum-health-check-api-server = { version = "3.0.0-develop", path = "pack
5858
torrust-axum-http-tracker-server = { version = "3.0.0-develop", path = "packages/axum-http-tracker-server" }
5959
torrust-axum-rest-tracker-api-server = { version = "3.0.0-develop", path = "packages/axum-rest-tracker-api-server" }
6060
torrust-axum-server = { version = "3.0.0-develop", path = "packages/axum-server" }
61+
torrust-rest-tracker-api-client = { version = "3.0.0-develop", path = "packages/rest-tracker-api-client" }
6162
torrust-rest-tracker-api-core = { version = "3.0.0-develop", path = "packages/rest-tracker-api-core" }
6263
torrust-server-lib = { version = "3.0.0-develop", path = "packages/server-lib" }
6364
torrust-tracker-clock = { version = "3.0.0-develop", path = "packages/clock" }
@@ -72,7 +73,6 @@ bittorrent-primitives = "0.1.0"
7273
bittorrent-tracker-client = { version = "3.0.0-develop", path = "packages/tracker-client" }
7374
local-ip-address = "0"
7475
mockall = "0"
75-
torrust-rest-tracker-api-client = { version = "3.0.0-develop", path = "packages/rest-tracker-api-client" }
7676
torrust-tracker-test-helpers = { version = "3.0.0-develop", path = "packages/test-helpers" }
7777

7878
[workspace]

compose.qbittorrent-e2e.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ services:
1919
ports:
2020
- "0:${QBT_E2E_TRACKER_HTTP_TRACKER_PORT:?QBT_E2E_TRACKER_HTTP_TRACKER_PORT is required}"
2121
- "0:${QBT_E2E_TRACKER_UDP_PORT:?QBT_E2E_TRACKER_UDP_PORT is required}/udp"
22+
- "0:${QBT_E2E_TRACKER_HTTP_API_PORT:?QBT_E2E_TRACKER_HTTP_API_PORT is required}"
2223
- "0:${QBT_E2E_TRACKER_HEALTH_CHECK_API_PORT:?QBT_E2E_TRACKER_HEALTH_CHECK_API_PORT is required}"
2324

2425
qbittorrent-seeder:

src/console/ci/qbittorrent_e2e/runner.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ pub async fn run() -> anyhow::Result<()> {
6868
let tracker_image = TrackerImage::new(&args.tracker_image);
6969
let qbittorrent_image = QbittorrentImage::new(&args.qbittorrent_image);
7070

71-
let (mut running_compose, seeder, leecher) = services_setup::start(
71+
let (mut running_compose, seeder, leecher, tracker) = services_setup::start(
7272
&args.compose_file,
7373
&project_name,
7474
&tracker_image,
@@ -78,7 +78,7 @@ pub async fn run() -> anyhow::Result<()> {
7878
)
7979
.await?;
8080

81-
scenarios::seeder_to_leecher_transfer::run(&seeder, &leecher, resources).await?;
81+
scenarios::seeder_to_leecher_transfer::run(&seeder, &leecher, &tracker, resources).await?;
8282

8383
// POST-SCENARIO: optionally keep containers for debugging.
8484
if args.keep_containers {

src/console/ci/qbittorrent_e2e/scenario_steps/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,13 @@
99
1010
mod fixtures;
1111
mod qbittorrent;
12+
mod tracker;
1213
mod verify_payload_integrity;
1314

1415
pub(super) use fixtures::{build_payload_fixture, build_torrent_fixture};
1516
pub(super) use qbittorrent::{
1617
add_torrent_file_to_client, ensure_torrent_is_absent, login_client, wait_until_download_completes,
1718
wait_until_torrent_appears_in_client,
1819
};
20+
pub(super) use tracker::verify_tracker_swarm;
1921
pub(super) use verify_payload_integrity::verify_payload_integrity;
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
//! Tracker API verification steps for E2E scenarios.
2+
//!
3+
//! Each file contains one explicit step so available actions are discoverable in the IDE tree.
4+
5+
mod verify_tracker_swarm;
6+
7+
pub(in super::super) use verify_tracker_swarm::verify_tracker_swarm;
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
use anyhow::Context;
2+
use torrust_axum_rest_tracker_api_server::v1::context::torrent::resources::torrent::Torrent;
3+
4+
use super::super::super::tracker::TrackerApiClient;
5+
use super::super::super::types::InfoHash;
6+
7+
/// Queries the tracker REST API and asserts that the torrent shows at least one
8+
/// seeder and at least one completed transfer.
9+
///
10+
/// This confirms that:
11+
/// - the seeder announced itself to the tracker (`seeders >= 1`)
12+
/// - the leecher sent a `completed` event after finishing the download (`completed >= 1`)
13+
///
14+
/// # Errors
15+
///
16+
/// Returns an error if the API request fails or either assertion does not hold.
17+
pub async fn verify_tracker_swarm(client: &TrackerApiClient, hash: &InfoHash) -> anyhow::Result<()> {
18+
let torrent: Torrent = client
19+
.get_torrent(hash)
20+
.await
21+
.with_context(|| format!("failed to query tracker swarm for torrent {hash}"))?;
22+
23+
tracing::info!(
24+
"Tracker swarm for {hash}: seeders={}, completed={}, leechers={}",
25+
torrent.seeders,
26+
torrent.completed,
27+
torrent.leechers
28+
);
29+
30+
anyhow::ensure!(
31+
torrent.seeders >= 1,
32+
"expected at least 1 seeder in tracker for torrent {hash}, got {} \
33+
— seeder did not announce to the tracker",
34+
torrent.seeders
35+
);
36+
37+
anyhow::ensure!(
38+
torrent.completed >= 1,
39+
"expected at least 1 completed transfer in tracker for torrent {hash}, got {} \
40+
— leecher did not send a completed event",
41+
torrent.completed
42+
);
43+
44+
tracing::info!("Tracker swarm verification passed for {hash}");
45+
46+
Ok(())
47+
}

src/console/ci/qbittorrent_e2e/scenarios/seeder_to_leecher_transfer.rs

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,10 @@ use anyhow::Context;
88

99
use super::super::qbittorrent::QbittorrentClient;
1010
use super::super::scenario_steps::{
11-
add_torrent_file_to_client, ensure_torrent_is_absent, login_client, verify_payload_integrity, wait_until_download_completes,
12-
wait_until_torrent_appears_in_client,
11+
add_torrent_file_to_client, ensure_torrent_is_absent, login_client, verify_payload_integrity, verify_tracker_swarm,
12+
wait_until_download_completes, wait_until_torrent_appears_in_client,
1313
};
14+
use super::super::tracker::TrackerApiClient;
1415
use super::super::workspace::WorkspaceResources;
1516

1617
/// Runs the seeder-to-leecher transfer scenario.
@@ -21,6 +22,7 @@ use super::super::workspace::WorkspaceResources;
2122
pub(crate) async fn run(
2223
seeder: &QbittorrentClient,
2324
leecher: &QbittorrentClient,
25+
tracker: &TrackerApiClient,
2426
workspace: &WorkspaceResources,
2527
) -> anyhow::Result<()> {
2628
let info_hash = workspace.shared.torrent.info_hash.clone();
@@ -123,5 +125,11 @@ pub(crate) async fn run(
123125
)
124126
.context("downloaded payload does not match the original")?;
125127

128+
// ASSERT: tracker registered both peers (seeder announced; leecher completed).
129+
130+
verify_tracker_swarm(tracker, &info_hash)
131+
.await
132+
.context("tracker swarm verification failed")?;
133+
126134
Ok(())
127135
}

src/console/ci/qbittorrent_e2e/services_setup.rs

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ use anyhow::Context;
1111

1212
use super::client_role::ClientRole;
1313
use super::qbittorrent::QbittorrentClient;
14-
use super::tracker::TrackerConfig;
14+
use super::tracker::{TrackerApiClient, TrackerConfig};
1515
use super::types::{ComposeProjectName, QbittorrentImage, TrackerImage};
1616
use super::workspace::WorkspaceResources;
1717
use crate::console::ci::compose::{DockerCompose, RunningCompose};
@@ -33,7 +33,7 @@ pub(crate) async fn start(
3333
qbittorrent_image: &QbittorrentImage,
3434
resources: &WorkspaceResources,
3535
tracker_config: &TrackerConfig,
36-
) -> anyhow::Result<(RunningCompose, QbittorrentClient, QbittorrentClient)> {
36+
) -> anyhow::Result<(RunningCompose, QbittorrentClient, QbittorrentClient, TrackerApiClient)> {
3737
let compose = configure_compose(
3838
compose_file,
3939
project_name,
@@ -44,8 +44,10 @@ pub(crate) async fn start(
4444
)?;
4545
compose.build().context("failed to build local tracker image")?;
4646
let running_compose = compose.up().context("failed to start qBittorrent compose stack")?;
47-
let (seeder, leecher) = build_clients(&compose, resources.timing.polling_deadline.as_duration()).await?;
48-
Ok((running_compose, seeder, leecher))
47+
let timeout = resources.timing.polling_deadline.as_duration();
48+
let (seeder, leecher) = build_clients(&compose, timeout).await?;
49+
let tracker = build_tracker_api_client(&compose, tracker_config, timeout).await?;
50+
Ok((running_compose, seeder, leecher, tracker))
4951
}
5052

5153
async fn build_clients(compose: &DockerCompose, timeout: Duration) -> anyhow::Result<(QbittorrentClient, QbittorrentClient)> {
@@ -54,6 +56,22 @@ async fn build_clients(compose: &DockerCompose, timeout: Duration) -> anyhow::Re
5456
Ok((seeder, leecher))
5557
}
5658

59+
async fn build_tracker_api_client(
60+
compose: &DockerCompose,
61+
tracker_config: &TrackerConfig,
62+
timeout: Duration,
63+
) -> anyhow::Result<TrackerApiClient> {
64+
let container_port = tracker_config.http_api_bind_address().port();
65+
let host_port = compose
66+
.wait_for_port_mapping("tracker", container_port, timeout, COMPOSE_PORT_POLL_INTERVAL, &[])
67+
.await
68+
.context("failed to resolve tracker REST API host port")?;
69+
70+
tracing::info!("Tracker REST API host port: {host_port}");
71+
72+
TrackerApiClient::new(host_port, tracker_config).context("failed to build tracker REST API client")
73+
}
74+
5775
async fn build_seeder_client(compose: &DockerCompose, timeout: Duration) -> anyhow::Result<QbittorrentClient> {
5876
let port = wait_for_client_port(compose, ClientRole::Seeder, timeout).await?;
5977
build_client(ClientRole::Seeder, port, timeout)
@@ -98,13 +116,15 @@ fn configure_compose(
98116
) -> anyhow::Result<DockerCompose> {
99117
let tracker_http_tracker_port = tracker_config.http_tracker_bind_address().port().to_string();
100118
let tracker_udp_port = tracker_config.udp_bind_address().port().to_string();
119+
let tracker_http_api_port = tracker_config.http_api_bind_address().port().to_string();
101120
let tracker_health_check_api_port = tracker_config.health_check_api_bind_address().port().to_string();
102121

103122
Ok(DockerCompose::new(compose_file, project_name.as_str())
104123
.with_env("QBT_E2E_TRACKER_IMAGE", tracker_image.as_str())
105124
.with_env("QBT_E2E_QBITTORRENT_IMAGE", qbittorrent_image.as_str())
106125
.with_env("QBT_E2E_TRACKER_HTTP_TRACKER_PORT", tracker_http_tracker_port.as_str())
107126
.with_env("QBT_E2E_TRACKER_UDP_PORT", tracker_udp_port.as_str())
127+
.with_env("QBT_E2E_TRACKER_HTTP_API_PORT", tracker_http_api_port.as_str())
108128
.with_env(
109129
"QBT_E2E_TRACKER_HEALTH_CHECK_API_PORT",
110130
tracker_health_check_api_port.as_str(),
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
//! Tracker REST API client, scoped to E2E test needs.
2+
//!
3+
//! Wraps the official [`torrust_rest_tracker_api_client::v1::Client`] so that
4+
//! future scenario steps can call any REST API endpoint through the same client
5+
//! without having to reconstruct connection details each time.
6+
use anyhow::Context;
7+
use torrust_axum_rest_tracker_api_server::v1::context::torrent::resources::torrent::Torrent;
8+
use torrust_rest_tracker_api_client::connection_info::{ConnectionInfo, Origin};
9+
use torrust_rest_tracker_api_client::v1::client::Client;
10+
11+
use super::super::types::InfoHash;
12+
use super::config_builder::TrackerConfig;
13+
14+
/// Wrapper around the official Torrust Tracker REST API client.
15+
///
16+
/// Provides typed, high-level helpers for the endpoints used in E2E test scenarios.
17+
/// All other endpoints are still reachable through the inner [`Client`].
18+
pub(crate) struct TrackerApiClient {
19+
inner: Client,
20+
}
21+
22+
impl TrackerApiClient {
23+
/// Creates a new client connected to the tracker REST API on the given host port.
24+
///
25+
/// # Errors
26+
///
27+
/// Returns an error if the origin URL cannot be parsed or the HTTP client
28+
/// cannot be built.
29+
pub(crate) fn new(host_port: u16, tracker_config: &TrackerConfig) -> anyhow::Result<Self> {
30+
let origin = Origin::new(&format!("http://127.0.0.1:{host_port}")) // DevSkim: ignore DS137138
31+
.context("failed to parse tracker REST API origin")?;
32+
33+
let connection_info = ConnectionInfo::authenticated(origin, tracker_config.access_token());
34+
35+
let inner = Client::new(connection_info).context("failed to build tracker REST API client")?;
36+
37+
Ok(Self { inner })
38+
}
39+
40+
/// Returns the full [`Torrent`] resource for the torrent identified by `hash`.
41+
///
42+
/// # Errors
43+
///
44+
/// Returns an error if the HTTP request fails, the server returns a non-2xx
45+
/// status, or the response body cannot be deserialized.
46+
pub(crate) async fn get_torrent(&self, hash: &InfoHash) -> anyhow::Result<Torrent> {
47+
let response = self.inner.get_torrent(hash.as_str(), None).await;
48+
49+
if !response.status().is_success() {
50+
return Err(anyhow::anyhow!(
51+
"tracker REST API returned status {} for torrent {hash}",
52+
response.status()
53+
));
54+
}
55+
56+
response
57+
.json::<Torrent>()
58+
.await
59+
.with_context(|| format!("failed to deserialize tracker torrent response for {hash}"))
60+
}
61+
}

src/console/ci/qbittorrent_e2e/tracker/config_builder.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,14 @@ impl TrackerConfig {
5252
self.health_check_api_bind_address
5353
}
5454

55+
pub(crate) fn http_api_bind_address(&self) -> SocketAddr {
56+
self.http_api_bind_address
57+
}
58+
59+
pub(crate) fn access_token(&self) -> &str {
60+
&self.access_token
61+
}
62+
5563
pub(crate) fn announce_url_for_compose_service(&self) -> String {
5664
let announce_url = format!("http://tracker:{}/announce", self.http_tracker_bind_address.port()); // DevSkim: ignore DS137138
5765

0 commit comments

Comments
 (0)