mirror of
https://github.com/twonlyapp/twonly-app.git
synced 2026-09-01 10:24:08 +00:00
249 lines
9.6 KiB
Rust
249 lines
9.6 KiB
Rust
use super::{init_tracing, Tester};
|
|
use rust_lib_twonly::bridge::api::ApiConnectionState;
|
|
use rust_lib_twonly::services::direct_media_upload::DirectMediaUploadService;
|
|
use rust_lib_twonly::services::media_upload::MediaUploadService;
|
|
|
|
async fn create_authenticated_tester() -> anyhow::Result<Tester> {
|
|
let mut tester = Tester::new().await?;
|
|
tester.wait_until(ApiConnectionState::Connected).await?;
|
|
tester.register_and_authenticate().await?;
|
|
tester.wait_until(ApiConnectionState::Authenticated).await?;
|
|
Ok(tester)
|
|
}
|
|
|
|
/// A native background transfer cannot report into a process that is not
|
|
/// running, so the client asks the server what happened to each attachment it
|
|
/// still believes is in flight. This drives that path against the live API.
|
|
#[tokio::test]
|
|
async fn test_direct_media_upload_reconciliation() -> anyhow::Result<()> {
|
|
init_tracing();
|
|
|
|
let tester = create_authenticated_tester().await?;
|
|
let service = DirectMediaUploadService::new(&tester.context);
|
|
|
|
let issued = service.preload_slots().await?;
|
|
assert!(issued > 0, "the server must issue direct-media slots");
|
|
|
|
let (attachment_id, capability) = {
|
|
let database = tester.context.app_db.read().await.clone();
|
|
sqlx::query_as::<_, (String, Vec<u8>)>(
|
|
"SELECT attachment_id, capability FROM direct_media_upload_slots WHERE state = 'cached' LIMIT 1",
|
|
)
|
|
.fetch_one(&database.pool)
|
|
.await?
|
|
};
|
|
|
|
// Stand in for a media send whose native transfer was handed off but never
|
|
// reported back.
|
|
let media_id = uuid::Uuid::new_v4().to_string();
|
|
let job_directory = std::path::PathBuf::from(&tester.context.config.data_dir)
|
|
.join("direct-media-upload")
|
|
.join(&attachment_id);
|
|
std::fs::create_dir_all(&job_directory)?;
|
|
let multipart_path = job_directory.join("media.multipart");
|
|
let manifest_path = job_directory.join("manifest.pb");
|
|
let complete_path = job_directory.join("complete.pb");
|
|
for path in [&multipart_path, &manifest_path, &complete_path] {
|
|
std::fs::write(path, b"")?;
|
|
}
|
|
{
|
|
let database = tester.context.app_db.read().await.clone();
|
|
sqlx::query!(
|
|
"INSERT INTO media_files(media_id, type, upload_state) VALUES (?, 'image', 'backgroundUploadTaskStarted')",
|
|
media_id,
|
|
)
|
|
.execute(&database.pool)
|
|
.await?;
|
|
let multipart = multipart_path.to_string_lossy().into_owned();
|
|
let manifest = manifest_path.to_string_lossy().into_owned();
|
|
let complete = complete_path.to_string_lossy().into_owned();
|
|
sqlx::query!(
|
|
r#"INSERT INTO direct_media_upload_jobs
|
|
(attachment_id, media_id, multipart_path, manifest_path,
|
|
complete_body_path, native_descriptor_json, state, expires_at)
|
|
SELECT ?, ?, ?, ?, ?, '{}', 'scheduled', expires_at
|
|
FROM direct_media_upload_slots WHERE attachment_id = ?"#,
|
|
attachment_id,
|
|
media_id,
|
|
multipart,
|
|
manifest,
|
|
complete,
|
|
attachment_id,
|
|
)
|
|
.execute(&database.pool)
|
|
.await?;
|
|
sqlx::query!(
|
|
"UPDATE direct_media_upload_slots SET state = 'reserved' WHERE attachment_id = ?",
|
|
attachment_id,
|
|
)
|
|
.execute(&database.pool)
|
|
.await?;
|
|
}
|
|
|
|
// Nothing has been uploaded, so the attachment is still reserved and the
|
|
// job must survive: giving up here would drop a transfer the OS may still
|
|
// be running.
|
|
service.reconcile().await?;
|
|
{
|
|
let database = tester.context.app_db.read().await.clone();
|
|
let jobs = sqlx::query_scalar!(
|
|
"SELECT COUNT(*) FROM direct_media_upload_jobs WHERE attachment_id = ?",
|
|
attachment_id,
|
|
)
|
|
.fetch_one(&database.pool)
|
|
.await?;
|
|
assert_eq!(jobs, 1, "a reserved attachment must not be settled");
|
|
}
|
|
assert!(multipart_path.exists(), "request files must be kept");
|
|
|
|
// The server now reports the attachment as abandoned. Reconciliation has to
|
|
// release the slot and the files, and requeue the media for another try.
|
|
abandon_attachment(&attachment_id, &capability).await?;
|
|
service.reconcile().await?;
|
|
|
|
{
|
|
let database = tester.context.app_db.read().await.clone();
|
|
let jobs = sqlx::query_scalar!(
|
|
"SELECT COUNT(*) FROM direct_media_upload_jobs WHERE attachment_id = ?",
|
|
attachment_id,
|
|
)
|
|
.fetch_one(&database.pool)
|
|
.await?;
|
|
assert_eq!(jobs, 0, "an abandoned attachment must settle its job");
|
|
|
|
let slot_state = sqlx::query_scalar!(
|
|
"SELECT state FROM direct_media_upload_slots WHERE attachment_id = ?",
|
|
attachment_id,
|
|
)
|
|
.fetch_one(&database.pool)
|
|
.await?;
|
|
assert_eq!(slot_state, "abandoned");
|
|
|
|
let upload_state = sqlx::query_scalar!(
|
|
"SELECT upload_state FROM media_files WHERE media_id = ?",
|
|
media_id,
|
|
)
|
|
.fetch_one(&database.pool)
|
|
.await?;
|
|
assert_eq!(
|
|
upload_state.as_deref(),
|
|
Some("preprocessing"),
|
|
"an abandoned upload has to be retried, not lost"
|
|
);
|
|
}
|
|
assert!(
|
|
!multipart_path.exists(),
|
|
"settling must delete the request files"
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Rust owns the media row and its key material; Flutter only names the type.
|
|
#[tokio::test]
|
|
async fn test_media_initialization_is_rust_owned() -> anyhow::Result<()> {
|
|
init_tracing();
|
|
|
|
let tester = create_authenticated_tester().await?;
|
|
let service = MediaUploadService::new(&tester.context);
|
|
|
|
// A value below one second can only have been meant as seconds.
|
|
let media_id = service.initialize("image".into(), Some(12), false).await?;
|
|
let database = tester.context.app_db.read().await.clone();
|
|
let row = sqlx::query!(
|
|
r#"SELECT upload_state, display_limit_in_milliseconds, encryption_key,
|
|
encryption_nonce, is_draft_media
|
|
FROM media_files WHERE media_id = ?"#,
|
|
media_id,
|
|
)
|
|
.fetch_one(&database.pool)
|
|
.await?;
|
|
assert_eq!(row.upload_state.as_deref(), Some("initialized"));
|
|
assert_eq!(row.display_limit_in_milliseconds, Some(12_000));
|
|
assert_eq!(row.encryption_key.map(|key| key.len()), Some(32));
|
|
assert_eq!(row.encryption_nonce.map(|nonce| nonce.len()), Some(12));
|
|
assert_eq!(row.is_draft_media, 0);
|
|
|
|
// Only one media file may be the draft, so creating a new draft clears the
|
|
// previous one.
|
|
let draft = service.initialize("image".into(), None, true).await?;
|
|
let second_draft = service.initialize("video".into(), None, true).await?;
|
|
let drafts = sqlx::query_scalar!("SELECT COUNT(*) FROM media_files WHERE is_draft_media = 1")
|
|
.fetch_one(&database.pool)
|
|
.await?;
|
|
assert_eq!(drafts, 1, "only the newest draft stays a draft");
|
|
let first_draft_state = sqlx::query_scalar!(
|
|
"SELECT is_draft_media FROM media_files WHERE media_id = ?",
|
|
draft
|
|
)
|
|
.fetch_one(&database.pool)
|
|
.await?;
|
|
assert_eq!(first_draft_state, 0);
|
|
let second_draft_state = sqlx::query_scalar!(
|
|
"SELECT is_draft_media FROM media_files WHERE media_id = ?",
|
|
second_draft
|
|
)
|
|
.fetch_one(&database.pool)
|
|
.await?;
|
|
assert_eq!(second_draft_state, 1);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// The send pipeline encodes stills itself now. This drives a real media file
|
|
/// through `start_upload` and checks that the plaintext handed to encryption is
|
|
/// a WebP that Rust produced, with no Flutter involvement.
|
|
#[tokio::test]
|
|
async fn test_outgoing_images_are_encoded_to_webp_by_rust() -> anyhow::Result<()> {
|
|
init_tracing();
|
|
|
|
let tester = create_authenticated_tester().await?;
|
|
let service = MediaUploadService::new(&tester.context);
|
|
let media_id = service.initialize("image".into(), None, false).await?;
|
|
|
|
let media_root = std::path::PathBuf::from(&tester.context.config.data_dir).join("mediafiles");
|
|
let original = media_root
|
|
.join("tmp")
|
|
.join(format!("{media_id}.original.webp"));
|
|
std::fs::create_dir_all(original.parent().expect("tmp directory"))?;
|
|
// A PNG carrying an alpha channel, so the encoder has to preserve it.
|
|
let mut source = image::RgbaImage::from_pixel(320, 240, image::Rgba([12, 34, 56, 255]));
|
|
source.put_pixel(0, 0, image::Rgba([0, 0, 0, 0]));
|
|
image::DynamicImage::ImageRgba8(source).save_with_format(&original, image::ImageFormat::Png)?;
|
|
|
|
// Scheduling the transfer needs a mobile background uploader and a
|
|
// recipient, so preparation stops right after compression on this host.
|
|
service.start_upload(&media_id).await?;
|
|
|
|
let plaintext = media_root.join("tmp").join(format!("{media_id}.webp"));
|
|
let bytes = std::fs::read(&plaintext)?;
|
|
assert_eq!(&bytes[0..4], b"RIFF", "the send plaintext must be a WebP");
|
|
assert_eq!(&bytes[8..12], b"WEBP");
|
|
let decoded = image::load_from_memory(&bytes)?;
|
|
assert_eq!((decoded.width(), decoded.height()), (320, 240));
|
|
assert!(
|
|
bytes.len() < std::fs::metadata(&original)?.len() as usize,
|
|
"the encoded image should be smaller than the PNG original"
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
async fn abandon_attachment(attachment_id: &str, capability: &[u8]) -> anyhow::Result<()> {
|
|
let url = format!(
|
|
"{}v2/attachments/{attachment_id}/abandon",
|
|
rust_lib_twonly::bridge::api::RustApi::api_base_url("https".into())
|
|
);
|
|
let response = reqwest::Client::new()
|
|
.post(url)
|
|
.header("x-twonly-upload-capability", hex::encode(capability))
|
|
.header("content-length", 0)
|
|
.send()
|
|
.await?;
|
|
anyhow::ensure!(
|
|
response.status().is_success(),
|
|
"abandon returned HTTP {}",
|
|
response.status()
|
|
);
|
|
Ok(())
|
|
}
|