diff --git a/.github/workflows/develop.yml b/.github/workflows/develop.yml
index dca7e7e1..59e05a71 100644
--- a/.github/workflows/develop.yml
+++ b/.github/workflows/develop.yml
@@ -24,6 +24,6 @@ jobs:
- uses: taiki-e/install-action@cargo-llvm-cov
- uses: taiki-e/install-action@nextest
- name: Test Coverage
- run: cargo llvm-cov nextest
+ run: cargo llvm-cov nextest
- name: E2E Tests
run: ./docker/bin/run-e2e-tests.sh
diff --git a/bin/install.sh b/bin/install.sh
index 041863b2..d4d33a43 100755
--- a/bin/install.sh
+++ b/bin/install.sh
@@ -8,7 +8,7 @@ fi
# Generate storage directory if it does not exist
mkdir -p "./storage/database"
-# Generate the sqlite database for the index baclend if it does not exist
+# Generate the sqlite database for the index backend if it does not exist
if ! [ -f "./storage/database/data.db" ]; then
# todo: it should get the path from config.toml and only do it when we use sqlite
touch ./storage/database/data.db
diff --git a/docker/bin/e2e-env-reset.sh b/docker/bin/e2e-env-reset.sh
new file mode 100755
index 00000000..ae7e3aff
--- /dev/null
+++ b/docker/bin/e2e-env-reset.sh
@@ -0,0 +1,26 @@
+#!/bin/bash
+
+# Delete the SQLite databases and recreate them.
+
+./docker/bin/e2e-env-down.sh
+
+rm -f ./storage/database/torrust_index_backend_e2e_testing.db
+rm -f ./storage/database/torrust_tracker_e2e_testing.db
+
+# Generate storage directory if it does not exist
+mkdir -p "./storage/database"
+
+# Generate the sqlite database for the index backend if it does not exist
+if ! [ -f "./storage/database/torrust_index_backend_e2e_testing.db" ]; then
+ # todo: it should get the path from config.toml and only do it when we use sqlite
+ touch ./storage/database/torrust_index_backend_e2e_testing.db
+ echo ";" | sqlite3 ./storage/database/torrust_index_backend_e2e_testing.db
+fi
+
+# Generate the sqlite database for the tracker if it does not exist
+if ! [ -f "./storage/database/torrust_tracker_e2e_testing.db" ]; then
+ touch ./storage/database/torrust_tracker_e2e_testing.db
+ echo ";" | sqlite3 ./storage/database/torrust_tracker_e2e_testing.db
+fi
+
+./docker/bin/e2e-env-up.sh
diff --git a/docker/bin/run-e2e-tests.sh b/docker/bin/run-e2e-tests.sh
index 5eb63c33..cc3b5351 100755
--- a/docker/bin/run-e2e-tests.sh
+++ b/docker/bin/run-e2e-tests.sh
@@ -35,6 +35,9 @@ wait_for_container_to_be_healthy() {
return 1
}
+# Install tool to create torrent files
+cargo install imdl
+
cp .env.local .env
./bin/install.sh
diff --git a/project-words.txt b/project-words.txt
index e2e24938..220cba8b 100644
--- a/project-words.txt
+++ b/project-words.txt
@@ -17,6 +17,8 @@ hasher
Hasher
httpseeds
imagoodboy
+imdl
+infohash
jsonwebtoken
leechers
Leechers
@@ -41,6 +43,8 @@ strftime
sublicensable
sublist
subpoints
+tempdir
+tempfile
torrust
Torrust
upgrader
diff --git a/tests/e2e/asserts.rs b/tests/e2e/asserts.rs
index 115637e7..f2968904 100644
--- a/tests/e2e/asserts.rs
+++ b/tests/e2e/asserts.rs
@@ -1,8 +1,8 @@
-use crate::e2e::response::Response;
+use crate::e2e::responses::TextResponse;
// Text responses
-pub fn assert_response_title(response: &Response, title: &str) {
+pub fn assert_response_title(response: &TextResponse, title: &str) {
let title_element = format!("
{title}");
assert!(
@@ -11,14 +11,14 @@ pub fn assert_response_title(response: &Response, title: &str) {
);
}
-pub fn assert_text_ok(response: &Response) {
+pub fn assert_text_ok(response: &TextResponse) {
assert_eq!(response.status, 200);
if let Some(content_type) = &response.content_type {
assert_eq!(content_type, "text/html; charset=utf-8");
}
}
-pub fn _assert_text_bad_request(response: &Response) {
+pub fn _assert_text_bad_request(response: &TextResponse) {
assert_eq!(response.status, 400);
if let Some(content_type) = &response.content_type {
assert_eq!(content_type, "text/plain; charset=utf-8");
@@ -27,7 +27,7 @@ pub fn _assert_text_bad_request(response: &Response) {
// JSON responses
-pub fn assert_json_ok(response: &Response) {
+pub fn assert_json_ok(response: &TextResponse) {
assert_eq!(response.status, 200);
if let Some(content_type) = &response.content_type {
assert_eq!(content_type, "application/json");
diff --git a/tests/e2e/client.rs b/tests/e2e/client.rs
index e668b3fb..727299aa 100644
--- a/tests/e2e/client.rs
+++ b/tests/e2e/client.rs
@@ -1,11 +1,14 @@
+use reqwest::multipart;
use serde::Serialize;
use super::contexts::category::{AddCategoryForm, DeleteCategoryForm};
use super::contexts::settings::UpdateSettingsForm;
+use super::contexts::torrent::requests::{TorrentId, UpdateTorrentFrom};
use super::contexts::user::{LoginForm, RegistrationForm, TokenRenewalForm, TokenVerificationForm, Username};
+use super::responses::{self, BinaryResponse};
use crate::e2e::connection_info::ConnectionInfo;
use crate::e2e::http::{Query, ReqwestQuery};
-use crate::e2e::response::Response;
+use crate::e2e::responses::TextResponse;
/// API Client
pub struct Client {
@@ -21,71 +24,99 @@ impl Client {
// Context: about
- pub async fn about(&self) -> Response {
+ pub async fn about(&self) -> TextResponse {
self.http_client.get("about", Query::empty()).await
}
- pub async fn license(&self) -> Response {
+ pub async fn license(&self) -> TextResponse {
self.http_client.get("about/license", Query::empty()).await
}
// Context: category
- pub async fn get_categories(&self) -> Response {
+ pub async fn get_categories(&self) -> TextResponse {
self.http_client.get("category", Query::empty()).await
}
- pub async fn add_category(&self, add_category_form: AddCategoryForm) -> Response {
+ pub async fn add_category(&self, add_category_form: AddCategoryForm) -> TextResponse {
self.http_client.post("category", &add_category_form).await
}
- pub async fn delete_category(&self, delete_category_form: DeleteCategoryForm) -> Response {
+ pub async fn delete_category(&self, delete_category_form: DeleteCategoryForm) -> TextResponse {
self.http_client.delete_with_body("category", &delete_category_form).await
}
// Context: root
- pub async fn root(&self) -> Response {
+ pub async fn root(&self) -> TextResponse {
self.http_client.get("", Query::empty()).await
}
// Context: settings
- pub async fn get_public_settings(&self) -> Response {
+ pub async fn get_public_settings(&self) -> TextResponse {
self.http_client.get("settings/public", Query::empty()).await
}
- pub async fn get_site_name(&self) -> Response {
+ pub async fn get_site_name(&self) -> TextResponse {
self.http_client.get("settings/name", Query::empty()).await
}
- pub async fn get_settings(&self) -> Response {
+ pub async fn get_settings(&self) -> TextResponse {
self.http_client.get("settings", Query::empty()).await
}
- pub async fn update_settings(&self, update_settings_form: UpdateSettingsForm) -> Response {
+ pub async fn update_settings(&self, update_settings_form: UpdateSettingsForm) -> TextResponse {
self.http_client.post("settings", &update_settings_form).await
}
+ // Context: torrent
+
+ pub async fn get_torrents(&self) -> TextResponse {
+ self.http_client.get("torrents", Query::empty()).await
+ }
+
+ pub async fn get_torrent(&self, id: TorrentId) -> TextResponse {
+ self.http_client.get(&format!("torrent/{id}"), Query::empty()).await
+ }
+
+ pub async fn delete_torrent(&self, id: TorrentId) -> TextResponse {
+ self.http_client.delete(&format!("torrent/{id}")).await
+ }
+
+ pub async fn update_torrent(&self, id: TorrentId, update_torrent_form: UpdateTorrentFrom) -> TextResponse {
+ self.http_client.put(&format!("torrent/{id}"), &update_torrent_form).await
+ }
+
+ pub async fn upload_torrent(&self, form: multipart::Form) -> TextResponse {
+ self.http_client.post_multipart("torrent/upload", form).await
+ }
+
+ pub async fn download_torrent(&self, id: TorrentId) -> responses::BinaryResponse {
+ self.http_client
+ .get_binary(&format!("torrent/download/{id}"), Query::empty())
+ .await
+ }
+
// Context: user
- pub async fn register_user(&self, registration_form: RegistrationForm) -> Response {
+ pub async fn register_user(&self, registration_form: RegistrationForm) -> TextResponse {
self.http_client.post("user/register", ®istration_form).await
}
- pub async fn login_user(&self, registration_form: LoginForm) -> Response {
+ pub async fn login_user(&self, registration_form: LoginForm) -> TextResponse {
self.http_client.post("user/login", ®istration_form).await
}
- pub async fn verify_token(&self, token_verification_form: TokenVerificationForm) -> Response {
+ pub async fn verify_token(&self, token_verification_form: TokenVerificationForm) -> TextResponse {
self.http_client.post("user/token/verify", &token_verification_form).await
}
- pub async fn renew_token(&self, token_verification_form: TokenRenewalForm) -> Response {
+ pub async fn renew_token(&self, token_verification_form: TokenRenewalForm) -> TextResponse {
self.http_client.post("user/token/renew", &token_verification_form).await
}
- pub async fn ban_user(&self, username: Username) -> Response {
+ pub async fn ban_user(&self, username: Username) -> TextResponse {
self.http_client.delete(&format!("user/ban/{}", &username.value)).await
}
}
@@ -104,7 +135,30 @@ impl Http {
}
}
- pub async fn get(&self, path: &str, params: Query) -> Response {
+ pub async fn get(&self, path: &str, params: Query) -> TextResponse {
+ let response = match &self.connection_info.token {
+ Some(token) => reqwest::Client::builder()
+ .build()
+ .unwrap()
+ .get(self.base_url(path).clone())
+ .query(&ReqwestQuery::from(params))
+ .bearer_auth(token)
+ .send()
+ .await
+ .unwrap(),
+ None => reqwest::Client::builder()
+ .build()
+ .unwrap()
+ .get(self.base_url(path).clone())
+ .query(&ReqwestQuery::from(params))
+ .send()
+ .await
+ .unwrap(),
+ };
+ TextResponse::from(response).await
+ }
+
+ pub async fn get_binary(&self, path: &str, params: Query) -> BinaryResponse {
let response = match &self.connection_info.token {
Some(token) => reqwest::Client::builder()
.build()
@@ -124,10 +178,10 @@ impl Http {
.await
.unwrap(),
};
- Response::from(response).await
+ BinaryResponse::from(response).await
}
- pub async fn post(&self, path: &str, form: &T) -> Response {
+ pub async fn post(&self, path: &str, form: &T) -> TextResponse {
let response = match &self.connection_info.token {
Some(token) => reqwest::Client::new()
.post(self.base_url(path).clone())
@@ -143,10 +197,52 @@ impl Http {
.await
.unwrap(),
};
- Response::from(response).await
+ TextResponse::from(response).await
+ }
+
+ pub async fn post_multipart(&self, path: &str, form: multipart::Form) -> TextResponse {
+ let response = match &self.connection_info.token {
+ Some(token) => reqwest::Client::builder()
+ .build()
+ .unwrap()
+ .post(self.base_url(path).clone())
+ .multipart(form)
+ .bearer_auth(token)
+ .send()
+ .await
+ .unwrap(),
+ None => reqwest::Client::builder()
+ .build()
+ .unwrap()
+ .post(self.base_url(path).clone())
+ .multipart(form)
+ .send()
+ .await
+ .unwrap(),
+ };
+ TextResponse::from(response).await
+ }
+
+ pub async fn put(&self, path: &str, form: &T) -> TextResponse {
+ let response = match &self.connection_info.token {
+ Some(token) => reqwest::Client::new()
+ .put(self.base_url(path).clone())
+ .bearer_auth(token)
+ .json(&form)
+ .send()
+ .await
+ .unwrap(),
+ None => reqwest::Client::new()
+ .put(self.base_url(path).clone())
+ .json(&form)
+ .send()
+ .await
+ .unwrap(),
+ };
+ TextResponse::from(response).await
}
- async fn delete(&self, path: &str) -> Response {
+ async fn delete(&self, path: &str) -> TextResponse {
let response = match &self.connection_info.token {
Some(token) => reqwest::Client::new()
.delete(self.base_url(path).clone())
@@ -160,10 +256,10 @@ impl Http {
.await
.unwrap(),
};
- Response::from(response).await
+ TextResponse::from(response).await
}
- async fn delete_with_body(&self, path: &str, form: &T) -> Response {
+ async fn delete_with_body(&self, path: &str, form: &T) -> TextResponse {
let response = match &self.connection_info.token {
Some(token) => reqwest::Client::new()
.delete(self.base_url(path).clone())
@@ -179,7 +275,7 @@ impl Http {
.await
.unwrap(),
};
- Response::from(response).await
+ TextResponse::from(response).await
}
fn base_url(&self, path: &str) -> String {
diff --git a/tests/e2e/contexts/category.rs b/tests/e2e/contexts/category.rs
index 417b2516..ca6ed51a 100644
--- a/tests/e2e/contexts/category.rs
+++ b/tests/e2e/contexts/category.rs
@@ -217,9 +217,17 @@ pub mod fixtures {
use super::AddCategoryForm;
use crate::e2e::contexts::user::fixtures::logged_in_admin;
use crate::e2e::environment::TestEnv;
- use crate::e2e::response::Response;
+ use crate::e2e::responses::TextResponse;
- pub async fn add_category(category_name: &str) -> Response {
+ pub fn software_predefined_category_name() -> String {
+ "software".to_string()
+ }
+
+ pub fn software_predefined_category_id() -> i64 {
+ 5
+ }
+
+ pub async fn add_category(category_name: &str) -> TextResponse {
let logged_in_admin = logged_in_admin().await;
let client = TestEnv::default().authenticated_client(&logged_in_admin.token);
diff --git a/tests/e2e/contexts/mod.rs b/tests/e2e/contexts/mod.rs
index 1ba75c5e..a6f14141 100644
--- a/tests/e2e/contexts/mod.rs
+++ b/tests/e2e/contexts/mod.rs
@@ -2,4 +2,5 @@ pub mod about;
pub mod category;
pub mod root;
pub mod settings;
+pub mod torrent;
pub mod user;
diff --git a/tests/e2e/contexts/torrent/asserts.rs b/tests/e2e/contexts/torrent/asserts.rs
new file mode 100644
index 00000000..d4b2be4a
--- /dev/null
+++ b/tests/e2e/contexts/torrent/asserts.rs
@@ -0,0 +1,45 @@
+use super::responses::TorrentDetails;
+
+/// Assert that the torrent details match the expected ones.
+/// It ignores some fields that are not relevant for the E2E tests
+/// or hard to assert due to the concurrent nature of the tests.
+pub fn assert_expected_torrent_details(torrent: &TorrentDetails, expected_torrent: &TorrentDetails) {
+ assert_eq!(
+ torrent.torrent_id, expected_torrent.torrent_id,
+ "torrent `file_size` mismatch"
+ );
+ assert_eq!(torrent.uploader, expected_torrent.uploader, "torrent `uploader` mismatch");
+ assert_eq!(torrent.info_hash, expected_torrent.info_hash, "torrent `info_hash` mismatch");
+ assert_eq!(torrent.title, expected_torrent.title, "torrent `title` mismatch");
+ assert_eq!(
+ torrent.description, expected_torrent.description,
+ "torrent `description` mismatch"
+ );
+ assert_eq!(
+ torrent.category.category_id, expected_torrent.category.category_id,
+ "torrent `category.category_id` mismatch"
+ );
+ assert_eq!(
+ torrent.category.name, expected_torrent.category.name,
+ "torrent `category.name` mismatch"
+ );
+ // assert_eq!(torrent.category.num_torrents, expected_torrent.category.num_torrents, "torrent `category.num_torrents` mismatch"); // Ignored
+ // assert_eq!(torrent.upload_date, expected_torrent.upload_date, "torrent `upload_date` mismatch"); // Ignored, can't mock time easily for now.
+ assert_eq!(torrent.file_size, expected_torrent.file_size, "torrent `file_size` mismatch");
+ assert_eq!(torrent.seeders, expected_torrent.seeders, "torrent `seeders` mismatch");
+ assert_eq!(torrent.leechers, expected_torrent.leechers, "torrent `leechers` mismatch");
+ assert_eq!(torrent.files, expected_torrent.files, "torrent `files` mismatch");
+ assert_eq!(torrent.trackers, expected_torrent.trackers, "torrent `trackers` mismatch");
+ assert_eq!(
+ torrent.magnet_link, expected_torrent.magnet_link,
+ "torrent `magnet_link` mismatch"
+ );
+}
+
+/// Full assert for test debugging purposes.
+pub fn _assert_eq_torrent_details(torrent: &TorrentDetails, expected_torrent: &TorrentDetails) {
+ assert_eq!(
+ torrent, expected_torrent,
+ "\nUnexpected torrent details:\n{torrent:#?} torrent details should match the previously indexed torrent:\n{expected_torrent:#?}"
+ );
+}
diff --git a/tests/e2e/contexts/torrent/contract.rs b/tests/e2e/contexts/torrent/contract.rs
new file mode 100644
index 00000000..e7521de8
--- /dev/null
+++ b/tests/e2e/contexts/torrent/contract.rs
@@ -0,0 +1,345 @@
+//! API contract for `torrent` context.
+
+/*
+todo:
+
+Download torrent file:
+
+- It should allow authenticated users to download a torrent with a personal tracker url
+
+Delete torrent:
+
+- After deleting a torrent, it should be removed from the tracker whitelist
+
+Get torrent info:
+
+- The torrent info:
+ - should contain the tracker URL
+ - If no user owned tracker key can be found, it should use the default tracker url
+ - If user owned tracker key can be found, it should use the personal tracker url
+ - should contain the magnet link with the trackers from the torrent file
+ - should contain realtime seeders and leechers from the tracker
+*/
+
+mod for_guests {
+ use torrust_index_backend::utils::parse_torrent::decode_torrent;
+
+ use crate::e2e::contexts::category::fixtures::software_predefined_category_id;
+ use crate::e2e::contexts::torrent::asserts::assert_expected_torrent_details;
+ use crate::e2e::contexts::torrent::fixtures::upload_random_torrent_to_index;
+ use crate::e2e::contexts::torrent::responses::{Category, File, TorrentDetails, TorrentDetailsResponse, TorrentListResponse};
+ use crate::e2e::contexts::user::fixtures::logged_in_user;
+ use crate::e2e::environment::TestEnv;
+
+ #[tokio::test]
+ #[cfg_attr(not(feature = "e2e-tests"), ignore)]
+ async fn it_should_allow_guests_to_get_torrents() {
+ let uploader = logged_in_user().await;
+ let (_test_torrent, indexed_torrent) = upload_random_torrent_to_index(&uploader).await;
+
+ let client = TestEnv::default().unauthenticated_client();
+
+ let response = client.get_torrents().await;
+
+ let torrent_list_response: TorrentListResponse = serde_json::from_str(&response.body).unwrap();
+
+ assert!(torrent_list_response.data.total > 0);
+ assert!(torrent_list_response.data.contains(indexed_torrent.torrent_id));
+ assert!(response.is_json_and_ok());
+ }
+
+ #[tokio::test]
+ #[cfg_attr(not(feature = "e2e-tests"), ignore)]
+ async fn it_should_allow_guests_to_get_torrent_details_searching_by_id() {
+ let uploader = logged_in_user().await;
+ let (test_torrent, uploaded_torrent) = upload_random_torrent_to_index(&uploader).await;
+
+ let client = TestEnv::default().unauthenticated_client();
+
+ let response = client.get_torrent(uploaded_torrent.torrent_id).await;
+
+ let torrent_details_response: TorrentDetailsResponse = serde_json::from_str(&response.body).unwrap();
+
+ let expected_torrent = TorrentDetails {
+ torrent_id: uploaded_torrent.torrent_id,
+ uploader: uploader.username,
+ info_hash: test_torrent.file_info.info_hash.to_uppercase(),
+ title: test_torrent.index_info.title.clone(),
+ description: test_torrent.index_info.description,
+ category: Category {
+ category_id: software_predefined_category_id(),
+ name: test_torrent.index_info.category,
+ num_torrents: 19, // Ignored in assertion
+ },
+ upload_date: "2023-04-27 07:56:08".to_string(), // Ignored in assertion
+ file_size: test_torrent.file_info.content_size,
+ seeders: 0,
+ leechers: 0,
+ files: vec![File {
+ path: vec![test_torrent.file_info.files[0].clone()],
+ // Using one file torrent for testing: content_size = first file size
+ length: test_torrent.file_info.content_size,
+ md5sum: None,
+ }],
+ // code-review: why is this duplicated?
+ trackers: vec!["udp://tracker:6969".to_string(), "udp://tracker:6969".to_string()],
+ magnet_link: format!(
+ // cspell:disable-next-line
+ "magnet:?xt=urn:btih:{}&dn={}&tr=udp%3A%2F%2Ftracker%3A6969&tr=udp%3A%2F%2Ftracker%3A6969",
+ test_torrent.file_info.info_hash.to_uppercase(),
+ test_torrent.index_info.title
+ ),
+ };
+
+ assert_expected_torrent_details(&torrent_details_response.data, &expected_torrent);
+ assert!(response.is_json_and_ok());
+ }
+
+ #[tokio::test]
+ #[cfg_attr(not(feature = "e2e-tests"), ignore)]
+ async fn it_should_allow_guests_to_download_a_torrent_file_searching_by_id() {
+ let uploader = logged_in_user().await;
+ let (test_torrent, uploaded_torrent) = upload_random_torrent_to_index(&uploader).await;
+
+ let client = TestEnv::default().unauthenticated_client();
+
+ let response = client.download_torrent(uploaded_torrent.torrent_id).await;
+
+ let torrent = decode_torrent(&response.bytes).unwrap();
+ let mut expected_torrent = decode_torrent(&test_torrent.index_info.torrent_file.contents).unwrap();
+
+ // code-review: The backend does not generate exactly the same torrent
+ // that was uploaded and created by the `imdl` command-line tool.
+ // So we need to update the expected torrent to match the one generated
+ // by the backend. For some of them it makes sense (`announce` and `announce_list`),
+ // for others it does not.
+ expected_torrent.info.private = Some(0);
+ expected_torrent.announce = Some("udp://tracker:6969".to_string());
+ expected_torrent.encoding = None;
+ expected_torrent.announce_list = Some(vec![vec!["udp://tracker:6969".to_string()]]);
+ expected_torrent.creation_date = None;
+ expected_torrent.created_by = None;
+
+ assert_eq!(torrent, expected_torrent);
+ assert!(response.is_bittorrent_and_ok());
+ }
+
+ #[tokio::test]
+ #[cfg_attr(not(feature = "e2e-tests"), ignore)]
+ async fn it_should_not_allow_guests_to_delete_torrents() {
+ let uploader = logged_in_user().await;
+ let (_test_torrent, uploaded_torrent) = upload_random_torrent_to_index(&uploader).await;
+
+ let client = TestEnv::default().unauthenticated_client();
+
+ let response = client.delete_torrent(uploaded_torrent.torrent_id).await;
+
+ assert_eq!(response.status, 401);
+ }
+}
+
+mod for_authenticated_users {
+
+ use crate::e2e::contexts::torrent::fixtures::random_torrent;
+ use crate::e2e::contexts::torrent::requests::UploadTorrentMultipartForm;
+ use crate::e2e::contexts::torrent::responses::UploadedTorrentResponse;
+ use crate::e2e::contexts::user::fixtures::logged_in_user;
+ use crate::e2e::environment::TestEnv;
+
+ #[tokio::test]
+ #[cfg_attr(not(feature = "e2e-tests"), ignore)]
+ async fn it_should_allow_authenticated_users_to_upload_new_torrents() {
+ let uploader = logged_in_user().await;
+ let client = TestEnv::default().authenticated_client(&uploader.token);
+
+ let test_torrent = random_torrent();
+
+ let form: UploadTorrentMultipartForm = test_torrent.index_info.into();
+
+ let response = client.upload_torrent(form.into()).await;
+
+ let _uploaded_torrent_response: UploadedTorrentResponse = serde_json::from_str(&response.body).unwrap();
+
+ // code-review: the response only returns the torrent autoincrement ID
+ // generated by the DB. So we can't assert that the torrent was uploaded.
+ // We could return the infohash.
+ // We are going to use the infohash to get the torrent. See issue:
+ // https://github.com/torrust/torrust-index-backend/issues/115
+
+ assert!(response.is_json_and_ok());
+ }
+
+ #[tokio::test]
+ #[cfg_attr(not(feature = "e2e-tests"), ignore)]
+ async fn it_should_not_allow_uploading_a_torrent_with_a_non_existing_category() {
+ let uploader = logged_in_user().await;
+ let client = TestEnv::default().authenticated_client(&uploader.token);
+
+ let mut test_torrent = random_torrent();
+
+ test_torrent.index_info.category = "non-existing-category".to_string();
+
+ let form: UploadTorrentMultipartForm = test_torrent.index_info.into();
+
+ let response = client.upload_torrent(form.into()).await;
+
+ assert_eq!(response.status, 400);
+ }
+
+ #[tokio::test]
+ #[cfg_attr(not(feature = "e2e-tests"), ignore)]
+ async fn it_should_not_allow_uploading_a_torrent_with_a_title_that_already_exists() {
+ let uploader = logged_in_user().await;
+ let client = TestEnv::default().authenticated_client(&uploader.token);
+
+ // Upload the first torrent
+ let first_torrent = random_torrent();
+ let first_torrent_title = first_torrent.index_info.title.clone();
+ let form: UploadTorrentMultipartForm = first_torrent.index_info.into();
+ let _response = client.upload_torrent(form.into()).await;
+
+ // Upload the second torrent with the same title as the first one
+ let mut second_torrent = random_torrent();
+ second_torrent.index_info.title = first_torrent_title;
+ let form: UploadTorrentMultipartForm = second_torrent.index_info.into();
+ let response = client.upload_torrent(form.into()).await;
+
+ assert_eq!(response.status, 400);
+ }
+
+ #[tokio::test]
+ #[cfg_attr(not(feature = "e2e-tests"), ignore)]
+ async fn it_should_not_allow_uploading_a_torrent_with_a_infohash_that_already_exists() {
+ let uploader = logged_in_user().await;
+ let client = TestEnv::default().authenticated_client(&uploader.token);
+
+ // Upload the first torrent
+ let first_torrent = random_torrent();
+ let mut first_torrent_clone = first_torrent.clone();
+ let first_torrent_title = first_torrent.index_info.title.clone();
+ let form: UploadTorrentMultipartForm = first_torrent.index_info.into();
+ let _response = client.upload_torrent(form.into()).await;
+
+ // Upload the second torrent with the same infohash as the first one.
+ // We need to change the title otherwise the torrent will be rejected
+ // because of the duplicate title.
+ first_torrent_clone.index_info.title = format!("{}-clone", first_torrent_title);
+ let form: UploadTorrentMultipartForm = first_torrent_clone.index_info.into();
+ let response = client.upload_torrent(form.into()).await;
+
+ assert_eq!(response.status, 400);
+ }
+
+ mod and_non_admins {
+ use crate::e2e::contexts::torrent::fixtures::upload_random_torrent_to_index;
+ use crate::e2e::contexts::user::fixtures::logged_in_user;
+ use crate::e2e::environment::TestEnv;
+
+ #[tokio::test]
+ #[cfg_attr(not(feature = "e2e-tests"), ignore)]
+ async fn it_should_not_allow_non_admins_to_delete_torrents() {
+ let uploader = logged_in_user().await;
+ let (_test_torrent, uploaded_torrent) = upload_random_torrent_to_index(&uploader).await;
+
+ let client = TestEnv::default().authenticated_client(&uploader.token);
+
+ let response = client.delete_torrent(uploaded_torrent.torrent_id).await;
+
+ assert_eq!(response.status, 403);
+ }
+ }
+
+ mod and_torrent_owners {
+ use crate::e2e::contexts::torrent::fixtures::upload_random_torrent_to_index;
+ use crate::e2e::contexts::torrent::requests::UpdateTorrentFrom;
+ use crate::e2e::contexts::torrent::responses::UpdatedTorrentResponse;
+ use crate::e2e::contexts::user::fixtures::logged_in_user;
+ use crate::e2e::environment::TestEnv;
+
+ #[tokio::test]
+ #[cfg_attr(not(feature = "e2e-tests"), ignore)]
+ async fn it_should_allow_torrent_owners_to_update_their_torrents() {
+ let uploader = logged_in_user().await;
+ let (test_torrent, uploaded_torrent) = upload_random_torrent_to_index(&uploader).await;
+
+ let client = TestEnv::default().authenticated_client(&uploader.token);
+
+ let new_title = format!("{}-new-title", test_torrent.index_info.title);
+ let new_description = format!("{}-new-description", test_torrent.index_info.description);
+
+ let response = client
+ .update_torrent(
+ uploaded_torrent.torrent_id,
+ UpdateTorrentFrom {
+ title: Some(new_title.clone()),
+ description: Some(new_description.clone()),
+ },
+ )
+ .await;
+
+ let updated_torrent_response: UpdatedTorrentResponse = serde_json::from_str(&response.body).unwrap();
+
+ let torrent = updated_torrent_response.data;
+
+ assert_eq!(torrent.title, new_title);
+ assert_eq!(torrent.description, new_description);
+ assert!(response.is_json_and_ok());
+ }
+ }
+
+ mod and_admins {
+ use crate::e2e::contexts::torrent::fixtures::upload_random_torrent_to_index;
+ use crate::e2e::contexts::torrent::requests::UpdateTorrentFrom;
+ use crate::e2e::contexts::torrent::responses::{DeletedTorrentResponse, UpdatedTorrentResponse};
+ use crate::e2e::contexts::user::fixtures::{logged_in_admin, logged_in_user};
+ use crate::e2e::environment::TestEnv;
+
+ #[tokio::test]
+ #[cfg_attr(not(feature = "e2e-tests"), ignore)]
+ async fn it_should_allow_admins_to_delete_torrents_searching_by_id() {
+ let uploader = logged_in_user().await;
+ let (_test_torrent, uploaded_torrent) = upload_random_torrent_to_index(&uploader).await;
+
+ let admin = logged_in_admin().await;
+ let client = TestEnv::default().authenticated_client(&admin.token);
+
+ let response = client.delete_torrent(uploaded_torrent.torrent_id).await;
+
+ let deleted_torrent_response: DeletedTorrentResponse = serde_json::from_str(&response.body).unwrap();
+
+ assert_eq!(deleted_torrent_response.data.torrent_id, uploaded_torrent.torrent_id);
+ assert!(response.is_json_and_ok());
+ }
+
+ #[tokio::test]
+ #[cfg_attr(not(feature = "e2e-tests"), ignore)]
+ async fn it_should_allow_admins_to_update_someone_elses_torrents() {
+ let uploader = logged_in_user().await;
+ let (test_torrent, uploaded_torrent) = upload_random_torrent_to_index(&uploader).await;
+
+ let logged_in_admin = logged_in_admin().await;
+ let client = TestEnv::default().authenticated_client(&logged_in_admin.token);
+
+ let new_title = format!("{}-new-title", test_torrent.index_info.title);
+ let new_description = format!("{}-new-description", test_torrent.index_info.description);
+
+ let response = client
+ .update_torrent(
+ uploaded_torrent.torrent_id,
+ UpdateTorrentFrom {
+ title: Some(new_title.clone()),
+ description: Some(new_description.clone()),
+ },
+ )
+ .await;
+
+ let updated_torrent_response: UpdatedTorrentResponse = serde_json::from_str(&response.body).unwrap();
+
+ let torrent = updated_torrent_response.data;
+
+ assert_eq!(torrent.title, new_title);
+ assert_eq!(torrent.description, new_description);
+ assert!(response.is_json_and_ok());
+ }
+ }
+}
diff --git a/tests/e2e/contexts/torrent/file.rs b/tests/e2e/contexts/torrent/file.rs
new file mode 100644
index 00000000..e06b1385
--- /dev/null
+++ b/tests/e2e/contexts/torrent/file.rs
@@ -0,0 +1,83 @@
+//! Utility functions for torrent files.
+//!
+//! It's a wrapper around the [imdl](https://crates.io/crates/imdl) program.
+use std::path::{Path, PathBuf};
+use std::process::Command;
+
+use serde::Deserialize;
+use which::which;
+
+/// Attributes parsed from a torrent file.
+#[derive(Deserialize, Clone)]
+pub struct TorrentFileInfo {
+ pub name: String,
+ pub comment: Option,
+ pub creation_date: u64,
+ pub created_by: String,
+ pub source: Option,
+ pub info_hash: String,
+ pub torrent_size: u64,
+ pub content_size: u64,
+ pub private: bool,
+ pub tracker: Option,
+ pub announce_list: Vec,
+ pub update_url: Option,
+ pub dht_nodes: Vec,
+ pub piece_size: u64,
+ pub piece_count: u64,
+ pub file_count: u64,
+ pub files: Vec,
+}
+
+/// Creates a torrent file for the given file.
+/// This function requires the `imdl` program to be installed.
+///
+pub fn create_torrent(dir: &Path, file_name: &str) -> PathBuf {
+ guard_that_torrent_edition_cmd_is_installed();
+
+ let input_file_path = Path::new(dir).join(file_name);
+ let output_file_path = Path::new(dir).join(format!("{file_name}.torrent"));
+
+ let _output = Command::new("imdl")
+ .args(["torrent", "create", "--show"])
+ .args(["--input", &format!("{}", input_file_path.to_string_lossy())])
+ .args(["--output", &format!("{}", output_file_path.to_string_lossy())])
+ .output()
+ .unwrap_or_else(|_| panic!("failed to create torrent file: {:?}", output_file_path.to_string_lossy()));
+
+ //io::stdout().write_all(&output.stdout).unwrap();
+ //io::stderr().write_all(&output.stderr).unwrap();
+
+ output_file_path
+}
+
+/// Parses torrent file.
+/// This function requires the `imdl` program to be installed.
+///
+pub fn parse_torrent(torrent_file_path: &Path) -> TorrentFileInfo {
+ guard_that_torrent_edition_cmd_is_installed();
+
+ let output = Command::new("imdl")
+ .args(["torrent", "show", "--json", &torrent_file_path.to_string_lossy()])
+ .output()
+ .unwrap_or_else(|_| panic!("failed to open torrent file: {:?}", &torrent_file_path.to_string_lossy()));
+
+ match std::str::from_utf8(&output.stdout) {
+ Ok(parsed_torrent_json) => {
+ let res: TorrentFileInfo = serde_json::from_str(parsed_torrent_json).unwrap();
+ res
+ }
+ Err(err) => panic!("got non UTF-8 data from 'imdl'. Error: {err}"),
+ }
+}
+
+/// It panics if the `imdl` console application is not installed.
+fn guard_that_torrent_edition_cmd_is_installed() {
+ const IMDL_BINARY: &str = "imdl";
+ match which(IMDL_BINARY) {
+ Ok(_path) => (),
+ Err(err) => {
+ panic!("Can't create torrent with \"imdl\": {err}. Please install it with: `cargo install imdl`");
+ }
+ }
+}
diff --git a/tests/e2e/contexts/torrent/fixtures.rs b/tests/e2e/contexts/torrent/fixtures.rs
new file mode 100644
index 00000000..96d69d9a
--- /dev/null
+++ b/tests/e2e/contexts/torrent/fixtures.rs
@@ -0,0 +1,145 @@
+use std::fs::File;
+use std::io::Write;
+use std::path::{Path, PathBuf};
+
+use tempfile::{tempdir, TempDir};
+use uuid::Uuid;
+
+use super::file::{create_torrent, parse_torrent, TorrentFileInfo};
+use super::requests::{BinaryFile, UploadTorrentMultipartForm};
+use super::responses::{Id, UploadedTorrentResponse};
+use crate::e2e::contexts::category::fixtures::software_predefined_category_name;
+use crate::e2e::contexts::user::LoggedInUserData;
+use crate::e2e::environment::TestEnv;
+
+/// Information about a torrent that is going to added to the index.
+#[derive(Clone)]
+pub struct TorrentIndexInfo {
+ pub title: String,
+ pub description: String,
+ pub category: String,
+ pub torrent_file: BinaryFile,
+}
+
+impl From for UploadTorrentMultipartForm {
+ fn from(indexed_torrent: TorrentIndexInfo) -> UploadTorrentMultipartForm {
+ UploadTorrentMultipartForm {
+ title: indexed_torrent.title,
+ description: indexed_torrent.description,
+ category: indexed_torrent.category,
+ torrent_file: indexed_torrent.torrent_file,
+ }
+ }
+}
+
+/// Torrent that has been added to the index.
+pub struct TorrentListedInIndex {
+ pub torrent_id: Id,
+ pub title: String,
+ pub description: String,
+ pub category: String,
+ pub torrent_file: BinaryFile,
+}
+
+impl TorrentListedInIndex {
+ pub fn from(torrent_to_index: TorrentIndexInfo, torrent_id: Id) -> Self {
+ Self {
+ torrent_id,
+ title: torrent_to_index.title,
+ description: torrent_to_index.description,
+ category: torrent_to_index.category,
+ torrent_file: torrent_to_index.torrent_file,
+ }
+ }
+}
+
+/// Add a new random torrent to the index
+pub async fn upload_random_torrent_to_index(uploader: &LoggedInUserData) -> (TestTorrent, TorrentListedInIndex) {
+ let random_torrent = random_torrent();
+ let indexed_torrent = upload_torrent(uploader, &random_torrent.index_info).await;
+ (random_torrent, indexed_torrent)
+}
+
+/// Upload a torrent to the index
+pub async fn upload_torrent(uploader: &LoggedInUserData, torrent: &TorrentIndexInfo) -> TorrentListedInIndex {
+ let client = TestEnv::default().authenticated_client(&uploader.token);
+
+ let form: UploadTorrentMultipartForm = torrent.clone().into();
+
+ let response = client.upload_torrent(form.into()).await;
+
+ let res: UploadedTorrentResponse = serde_json::from_str(&response.body).unwrap();
+
+ TorrentListedInIndex::from(torrent.clone(), res.data.torrent_id)
+}
+
+#[derive(Clone)]
+pub struct TestTorrent {
+ /// Parsed info from torrent file.
+ pub file_info: TorrentFileInfo,
+ /// Torrent info needed to add the torrent to the index.
+ pub index_info: TorrentIndexInfo,
+}
+
+impl TestTorrent {
+ pub fn random() -> Self {
+ let temp_dir = temp_dir();
+
+ let torrents_dir_path = temp_dir.path().to_owned();
+
+ // Random ID to identify all the torrent related entities: files, fields, ...
+ // That makes easier to debug the tests outputs.
+ let id = Uuid::new_v4();
+
+ // Create a random torrent file
+ let torrent_path = random_torrent_file(&torrents_dir_path, &id);
+
+ // Load torrent binary file
+ let torrent_file = BinaryFile::from_file_at_path(&torrent_path);
+
+ // Load torrent file metadata
+ let torrent_info = parse_torrent(&torrent_path);
+
+ let torrent_to_index = TorrentIndexInfo {
+ title: format!("title-{id}"),
+ description: format!("description-{id}"),
+ category: software_predefined_category_name(),
+ torrent_file,
+ };
+
+ TestTorrent {
+ file_info: torrent_info,
+ index_info: torrent_to_index,
+ }
+ }
+}
+
+pub fn random_torrent() -> TestTorrent {
+ TestTorrent::random()
+}
+
+pub fn random_torrent_file(dir: &Path, id: &Uuid) -> PathBuf {
+ // Create random text file
+ let file_name = random_txt_file(dir, id);
+
+ // Create torrent file for the text file
+ create_torrent(dir, &file_name)
+}
+
+pub fn random_txt_file(dir: &Path, id: &Uuid) -> String {
+ // Sample file name
+ let file_name = format!("file-{id}.txt");
+
+ // Sample file path
+ let file_path = dir.join(file_name.clone());
+
+ // Write sample text to the temporary file
+ let mut file = File::create(file_path).unwrap();
+ file.write_all(id.as_bytes()).unwrap();
+
+ file_name
+}
+
+pub fn temp_dir() -> TempDir {
+ tempdir().unwrap()
+}
diff --git a/tests/e2e/contexts/torrent/mod.rs b/tests/e2e/contexts/torrent/mod.rs
new file mode 100644
index 00000000..4f3882e6
--- /dev/null
+++ b/tests/e2e/contexts/torrent/mod.rs
@@ -0,0 +1,6 @@
+pub mod asserts;
+pub mod contract;
+pub mod file;
+pub mod fixtures;
+pub mod requests;
+pub mod responses;
diff --git a/tests/e2e/contexts/torrent/requests.rs b/tests/e2e/contexts/torrent/requests.rs
new file mode 100644
index 00000000..fb1ec578
--- /dev/null
+++ b/tests/e2e/contexts/torrent/requests.rs
@@ -0,0 +1,51 @@
+use std::fs;
+use std::path::Path;
+
+use reqwest::multipart::Form;
+use serde::{Deserialize, Serialize};
+
+pub type TorrentId = i64;
+
+pub struct UploadTorrentMultipartForm {
+ pub title: String,
+ pub description: String,
+ pub category: String,
+ pub torrent_file: BinaryFile,
+}
+
+#[derive(Clone)]
+pub struct BinaryFile {
+ pub name: String,
+ pub contents: Vec,
+}
+
+impl BinaryFile {
+ pub fn from_file_at_path(path: &Path) -> Self {
+ BinaryFile {
+ name: path.file_name().unwrap().to_owned().into_string().unwrap(),
+ contents: fs::read(path).unwrap(),
+ }
+ }
+}
+
+impl From for Form {
+ fn from(form: UploadTorrentMultipartForm) -> Self {
+ Form::new()
+ .text("title", form.title)
+ .text("description", form.description)
+ .text("category", form.category)
+ .part(
+ "torrent",
+ reqwest::multipart::Part::bytes(form.torrent_file.contents)
+ .file_name(form.torrent_file.name)
+ .mime_str("application/x-bittorrent")
+ .unwrap(),
+ )
+ }
+}
+
+#[derive(Deserialize, Serialize)]
+pub struct UpdateTorrentFrom {
+ pub title: Option,
+ pub description: Option,
+}
diff --git a/tests/e2e/contexts/torrent/responses.rs b/tests/e2e/contexts/torrent/responses.rs
new file mode 100644
index 00000000..f2b6739c
--- /dev/null
+++ b/tests/e2e/contexts/torrent/responses.rs
@@ -0,0 +1,104 @@
+use serde::Deserialize;
+
+pub type Id = i64;
+pub type CategoryId = i64;
+pub type UtcDateTime = String; // %Y-%m-%d %H:%M:%S
+
+#[derive(Deserialize, PartialEq, Debug)]
+pub struct ErrorResponse {
+ pub error: String,
+}
+
+#[derive(Deserialize)]
+pub struct TorrentListResponse {
+ pub data: TorrentList,
+}
+
+#[derive(Deserialize, PartialEq, Debug)]
+pub struct TorrentList {
+ pub total: u32,
+ pub results: Vec,
+}
+
+impl TorrentList {
+ pub fn contains(&self, torrent_id: Id) -> bool {
+ self.results.iter().any(|item| item.torrent_id == torrent_id)
+ }
+}
+
+#[derive(Deserialize, PartialEq, Debug)]
+pub struct ListItem {
+ pub torrent_id: i64,
+ pub uploader: String,
+ pub info_hash: String,
+ pub title: String,
+ pub description: Option,
+ pub category_id: i64,
+ pub date_uploaded: String,
+ pub file_size: i64,
+ pub seeders: i64,
+ pub leechers: i64,
+}
+
+#[derive(Deserialize, PartialEq, Debug)]
+pub struct TorrentDetailsResponse {
+ pub data: TorrentDetails,
+}
+
+#[derive(Deserialize, PartialEq, Debug)]
+pub struct TorrentDetails {
+ pub torrent_id: Id,
+ pub uploader: String,
+ pub info_hash: String,
+ pub title: String,
+ pub description: String,
+ pub category: Category,
+ pub upload_date: UtcDateTime,
+ pub file_size: u64,
+ pub seeders: u64,
+ pub leechers: u64,
+ pub files: Vec,
+ pub trackers: Vec,
+ pub magnet_link: String,
+}
+
+#[derive(Deserialize, PartialEq, Debug)]
+pub struct Category {
+ pub category_id: CategoryId,
+ pub name: String,
+ pub num_torrents: u64,
+}
+
+#[derive(Deserialize, PartialEq, Debug)]
+pub struct File {
+ pub path: Vec,
+ pub length: u64,
+ pub md5sum: Option,
+}
+
+#[derive(Deserialize, PartialEq, Debug)]
+pub struct UploadedTorrentResponse {
+ pub data: UploadedTorrent,
+}
+
+#[derive(Deserialize, PartialEq, Debug)]
+pub struct UploadedTorrent {
+ pub torrent_id: Id,
+}
+
+#[derive(Deserialize, PartialEq, Debug)]
+pub struct DeletedTorrentResponse {
+ pub data: DeletedTorrent,
+}
+
+#[derive(Deserialize, PartialEq, Debug)]
+pub struct DeletedTorrent {
+ pub torrent_id: Id,
+}
+
+#[derive(Deserialize, PartialEq, Debug)]
+pub struct UpdatedTorrentResponse {
+ pub data: UpdatedTorrent,
+}
+
+pub type UpdatedTorrent = TorrentDetails;
diff --git a/tests/e2e/mod.rs b/tests/e2e/mod.rs
index 80c340b7..ce9ef1d1 100644
--- a/tests/e2e/mod.rs
+++ b/tests/e2e/mod.rs
@@ -35,4 +35,4 @@ mod connection_info;
mod contexts;
mod environment;
mod http;
-mod response;
+mod responses;
diff --git a/tests/e2e/response.rs b/tests/e2e/response.rs
deleted file mode 100644
index 70261cf2..00000000
--- a/tests/e2e/response.rs
+++ /dev/null
@@ -1,21 +0,0 @@
-use reqwest::Response as ReqwestResponse;
-
-#[derive(Debug)]
-pub struct Response {
- pub status: u16,
- pub content_type: Option,
- pub body: String,
-}
-
-impl Response {
- pub async fn from(response: ReqwestResponse) -> Self {
- Self {
- status: response.status().as_u16(),
- content_type: response
- .headers()
- .get("content-type")
- .map(|content_type| content_type.to_str().unwrap().to_owned()),
- body: response.text().await.unwrap(),
- }
- }
-}
diff --git a/tests/e2e/responses.rs b/tests/e2e/responses.rs
new file mode 100644
index 00000000..0be8bbd6
--- /dev/null
+++ b/tests/e2e/responses.rs
@@ -0,0 +1,70 @@
+use reqwest::Response as ReqwestResponse;
+
+#[derive(Debug)]
+pub struct TextResponse {
+ pub status: u16,
+ pub content_type: Option,
+ pub body: String,
+}
+
+impl TextResponse {
+ pub async fn from(response: ReqwestResponse) -> Self {
+ Self {
+ status: response.status().as_u16(),
+ content_type: response
+ .headers()
+ .get("content-type")
+ .map(|content_type| content_type.to_str().unwrap().to_owned()),
+ body: response.text().await.unwrap(),
+ }
+ }
+
+ pub fn is_json_and_ok(&self) -> bool {
+ self.is_ok() && self.is_json()
+ }
+
+ pub fn is_json(&self) -> bool {
+ if let Some(content_type) = &self.content_type {
+ return content_type == "application/json";
+ }
+ false
+ }
+
+ pub fn is_ok(&self) -> bool {
+ self.status == 200
+ }
+}
+
+#[derive(Debug)]
+pub struct BinaryResponse {
+ pub status: u16,
+ pub content_type: Option,
+ pub bytes: Vec,
+}
+
+impl BinaryResponse {
+ pub async fn from(response: ReqwestResponse) -> Self {
+ Self {
+ status: response.status().as_u16(),
+ content_type: response
+ .headers()
+ .get("content-type")
+ .map(|content_type| content_type.to_str().unwrap().to_owned()),
+ bytes: response.bytes().await.unwrap().to_vec(),
+ }
+ }
+ pub fn is_bittorrent_and_ok(&self) -> bool {
+ self.is_ok() && self.is_bittorrent()
+ }
+
+ pub fn is_bittorrent(&self) -> bool {
+ if let Some(content_type) = &self.content_type {
+ return content_type == "application/x-bittorrent";
+ }
+ false
+ }
+
+ pub fn is_ok(&self) -> bool {
+ self.status == 200
+ }
+}