tests
This commit is contained in:
Generated
+26
@@ -1979,6 +1979,7 @@ version = "0.1.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"argon2",
|
"argon2",
|
||||||
|
"async-trait",
|
||||||
"aws-config",
|
"aws-config",
|
||||||
"aws-credential-types",
|
"aws-credential-types",
|
||||||
"aws-sdk-s3",
|
"aws-sdk-s3",
|
||||||
@@ -1990,7 +1991,10 @@ dependencies = [
|
|||||||
"diesel_migrations",
|
"diesel_migrations",
|
||||||
"dotenv",
|
"dotenv",
|
||||||
"hex",
|
"hex",
|
||||||
|
"http-body-util",
|
||||||
|
"hyper 1.7.0",
|
||||||
"jsonwebtoken",
|
"jsonwebtoken",
|
||||||
|
"once_cell",
|
||||||
"rand 0.8.5",
|
"rand 0.8.5",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
@@ -2054,6 +2058,26 @@ version = "2.3.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pin-project"
|
||||||
|
version = "1.1.10"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "677f1add503faace112b9f1373e43e9e054bfdd22ff1a63c1bc485eaec6a6a8a"
|
||||||
|
dependencies = [
|
||||||
|
"pin-project-internal",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pin-project-internal"
|
||||||
|
version = "1.1.10"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pin-project-lite"
|
name = "pin-project-lite"
|
||||||
version = "0.2.16"
|
version = "0.2.16"
|
||||||
@@ -2931,7 +2955,9 @@ version = "0.4.13"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c"
|
checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"futures-core",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
|
"pin-project",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tower-layer",
|
"tower-layer",
|
||||||
|
|||||||
+7
-1
@@ -7,7 +7,7 @@ edition = "2021"
|
|||||||
# Web framework
|
# Web framework
|
||||||
axum = { version = "0.7", features = ["multipart"] }
|
axum = { version = "0.7", features = ["multipart"] }
|
||||||
tokio = { version = "1", features = ["full"] }
|
tokio = { version = "1", features = ["full"] }
|
||||||
tower = { version = "0.4", features = ["make"] }
|
tower = { version = "0.4", features = ["make", "util"] }
|
||||||
tower-http = { version = "0.5", features = ["cors", "trace"] }
|
tower-http = { version = "0.5", features = ["cors", "trace"] }
|
||||||
axum-extra = { version = "0.9", features = ["typed-header"] }
|
axum-extra = { version = "0.9", features = ["typed-header"] }
|
||||||
|
|
||||||
@@ -33,6 +33,7 @@ dotenv = "0.15"
|
|||||||
sha2 = "0.10"
|
sha2 = "0.10"
|
||||||
hex = "0.4"
|
hex = "0.4"
|
||||||
bytes = "1.5"
|
bytes = "1.5"
|
||||||
|
async-trait = "0.1"
|
||||||
|
|
||||||
# Error handling
|
# Error handling
|
||||||
thiserror = "1.0"
|
thiserror = "1.0"
|
||||||
@@ -44,3 +45,8 @@ jsonwebtoken = "9"
|
|||||||
|
|
||||||
# Misc
|
# Misc
|
||||||
rand = "0.8"
|
rand = "0.8"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
once_cell = "1.19"
|
||||||
|
hyper = "1.2"
|
||||||
|
http-body-util = "0.1"
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
pub mod auth;
|
||||||
|
pub mod config;
|
||||||
|
pub mod db;
|
||||||
|
pub mod error;
|
||||||
|
pub mod models;
|
||||||
|
pub mod routes;
|
||||||
|
pub mod s3;
|
||||||
|
pub mod schema;
|
||||||
|
pub mod state;
|
||||||
|
pub mod storage;
|
||||||
+10
-15
@@ -1,23 +1,17 @@
|
|||||||
mod auth;
|
|
||||||
mod config;
|
|
||||||
mod db;
|
|
||||||
mod error;
|
|
||||||
mod models;
|
|
||||||
mod routes;
|
|
||||||
mod s3;
|
|
||||||
mod schema;
|
|
||||||
mod state;
|
|
||||||
|
|
||||||
use std::net::SocketAddr;
|
use std::net::SocketAddr;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
use tokio::net::TcpListener;
|
use tokio::net::TcpListener;
|
||||||
use tower::make::Shared;
|
use tower::make::Shared;
|
||||||
use tracing_subscriber::EnvFilter;
|
use tracing_subscriber::EnvFilter;
|
||||||
|
|
||||||
use crate::auth::jwt::JwtService;
|
use paperless_backend::auth::jwt::JwtService;
|
||||||
use crate::config::AppConfig;
|
use paperless_backend::config::AppConfig;
|
||||||
use crate::s3::build_client;
|
use paperless_backend::db;
|
||||||
use crate::state::AppState;
|
use paperless_backend::routes;
|
||||||
|
use paperless_backend::s3::build_client;
|
||||||
|
use paperless_backend::state::AppState;
|
||||||
|
use paperless_backend::storage::S3Storage;
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> anyhow::Result<()> {
|
async fn main() -> anyhow::Result<()> {
|
||||||
@@ -27,9 +21,10 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
let config = AppConfig::from_env()?;
|
let config = AppConfig::from_env()?;
|
||||||
let pool = db::init_pool(&config.database_url)?;
|
let pool = db::init_pool(&config.database_url)?;
|
||||||
let s3_client = build_client(&config).await?;
|
let s3_client = build_client(&config).await?;
|
||||||
|
let storage = Arc::new(S3Storage::new(s3_client, config.s3_bucket.clone()));
|
||||||
let jwt = JwtService::from_config(&config)?;
|
let jwt = JwtService::from_config(&config)?;
|
||||||
|
|
||||||
let state = AppState::new(pool, config, s3_client, jwt);
|
let state = AppState::new(pool, config, storage, jwt);
|
||||||
|
|
||||||
let router = routes::create_router(state.clone());
|
let router = routes::create_router(state.clone());
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use aws_sdk_s3::presigning::PresigningConfig;
|
|
||||||
use aws_sdk_s3::primitives::ByteStream;
|
|
||||||
use axum::extract::{Json, Multipart, Path, Query, State};
|
use axum::extract::{Json, Multipart, Path, Query, State};
|
||||||
use axum::http::StatusCode;
|
use axum::http::StatusCode;
|
||||||
use axum::response::IntoResponse;
|
use axum::response::IntoResponse;
|
||||||
@@ -267,21 +265,11 @@ pub async fn upload_document(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut put_request = state
|
state
|
||||||
.s3
|
.storage
|
||||||
.put_object()
|
.put_object(&s3_key, file_bytes.clone(), content_type.clone())
|
||||||
.bucket(&state.config.s3_bucket)
|
|
||||||
.key(&s3_key)
|
|
||||||
.body(ByteStream::from(file_bytes.clone()));
|
|
||||||
|
|
||||||
if let Some(ref ct) = content_type {
|
|
||||||
put_request = put_request.content_type(ct.clone());
|
|
||||||
}
|
|
||||||
|
|
||||||
put_request
|
|
||||||
.send()
|
|
||||||
.await
|
.await
|
||||||
.map_err(|err| AppError::internal(format!("failed to upload to s3: {err}")))?;
|
.map_err(|err| AppError::internal(format!("failed to store document: {err}")))?;
|
||||||
|
|
||||||
let metadata_value = if metadata.is_null() {
|
let metadata_value = if metadata.is_null() {
|
||||||
Value::Object(Default::default())
|
Value::Object(Default::default())
|
||||||
@@ -349,22 +337,17 @@ pub async fn download_document(
|
|||||||
.filter(document_versions::version_number.eq(doc.current_version))
|
.filter(document_versions::version_number.eq(doc.current_version))
|
||||||
.first(&mut conn)?;
|
.first(&mut conn)?;
|
||||||
|
|
||||||
let presign_config = PresigningConfig::builder()
|
let presigned_url = state
|
||||||
.expires_in(Duration::from_secs(PRESIGNED_URL_EXPIRY_SECONDS))
|
.storage
|
||||||
.build()
|
.presign_get_object(
|
||||||
.map_err(|err| AppError::internal(format!("failed to build presigning config: {err}")))?;
|
&version.s3_key,
|
||||||
|
Duration::from_secs(PRESIGNED_URL_EXPIRY_SECONDS),
|
||||||
let presigned = state
|
)
|
||||||
.s3
|
|
||||||
.get_object()
|
|
||||||
.bucket(&state.config.s3_bucket)
|
|
||||||
.key(&version.s3_key)
|
|
||||||
.presigned(presign_config)
|
|
||||||
.await
|
.await
|
||||||
.map_err(|err| AppError::internal(format!("failed to generate download URL: {err}")))?;
|
.map_err(|err| AppError::internal(format!("failed to generate download URL: {err}")))?;
|
||||||
|
|
||||||
Ok(Json(DocumentDownloadResponse {
|
Ok(Json(DocumentDownloadResponse {
|
||||||
url: presigned.uri().to_string(),
|
url: presigned_url,
|
||||||
expires_in: PRESIGNED_URL_EXPIRY_SECONDS,
|
expires_in: PRESIGNED_URL_EXPIRY_SECONDS,
|
||||||
filename: doc.original_name.clone(),
|
filename: doc.original_name.clone(),
|
||||||
content_type: doc.content_type.clone(),
|
content_type: doc.content_type.clone(),
|
||||||
|
|||||||
@@ -16,8 +16,6 @@ diesel::table! {
|
|||||||
version_number -> Int4,
|
version_number -> Int4,
|
||||||
#[max_length = 500]
|
#[max_length = 500]
|
||||||
s3_key -> Varchar,
|
s3_key -> Varchar,
|
||||||
#[max_length = 100]
|
|
||||||
s3_bucket -> Varchar,
|
|
||||||
size_bytes -> Int8,
|
size_bytes -> Int8,
|
||||||
#[max_length = 64]
|
#[max_length = 64]
|
||||||
checksum -> Varchar,
|
checksum -> Varchar,
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use aws_sdk_s3::Client as S3Client;
|
|
||||||
use diesel::{
|
use diesel::{
|
||||||
pg::PgConnection,
|
pg::PgConnection,
|
||||||
r2d2::{ConnectionManager, PooledConnection},
|
r2d2::{ConnectionManager, PooledConnection},
|
||||||
@@ -11,6 +10,7 @@ use crate::{
|
|||||||
config::AppConfig,
|
config::AppConfig,
|
||||||
db::PgPool,
|
db::PgPool,
|
||||||
error::{AppError, AppResult},
|
error::{AppError, AppResult},
|
||||||
|
storage::ObjectStorage,
|
||||||
};
|
};
|
||||||
|
|
||||||
type PgPooledConnection = PooledConnection<ConnectionManager<PgConnection>>;
|
type PgPooledConnection = PooledConnection<ConnectionManager<PgConnection>>;
|
||||||
@@ -19,16 +19,21 @@ type PgPooledConnection = PooledConnection<ConnectionManager<PgConnection>>;
|
|||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
pub pool: PgPool,
|
pub pool: PgPool,
|
||||||
pub config: Arc<AppConfig>,
|
pub config: Arc<AppConfig>,
|
||||||
pub s3: S3Client,
|
pub storage: Arc<dyn ObjectStorage>,
|
||||||
pub jwt: JwtService,
|
pub jwt: JwtService,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AppState {
|
impl AppState {
|
||||||
pub fn new(pool: PgPool, config: AppConfig, s3: S3Client, jwt: JwtService) -> Self {
|
pub fn new(
|
||||||
|
pool: PgPool,
|
||||||
|
config: AppConfig,
|
||||||
|
storage: Arc<dyn ObjectStorage>,
|
||||||
|
jwt: JwtService,
|
||||||
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
pool,
|
pool,
|
||||||
config: Arc::new(config),
|
config: Arc::new(config),
|
||||||
s3,
|
storage,
|
||||||
jwt,
|
jwt,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use aws_sdk_s3::presigning::PresigningConfig;
|
||||||
|
use aws_sdk_s3::primitives::ByteStream;
|
||||||
|
use aws_sdk_s3::Client as S3Client;
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub trait ObjectStorage: Send + Sync + 'static {
|
||||||
|
async fn put_object(
|
||||||
|
&self,
|
||||||
|
key: &str,
|
||||||
|
bytes: Vec<u8>,
|
||||||
|
content_type: Option<String>,
|
||||||
|
) -> Result<()>;
|
||||||
|
|
||||||
|
async fn presign_get_object(&self, key: &str, expires_in: Duration) -> Result<String>;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct S3Storage {
|
||||||
|
client: S3Client,
|
||||||
|
bucket: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl S3Storage {
|
||||||
|
pub fn new(client: S3Client, bucket: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
client,
|
||||||
|
bucket: bucket.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl ObjectStorage for S3Storage {
|
||||||
|
async fn put_object(
|
||||||
|
&self,
|
||||||
|
key: &str,
|
||||||
|
bytes: Vec<u8>,
|
||||||
|
content_type: Option<String>,
|
||||||
|
) -> Result<()> {
|
||||||
|
let mut request = self
|
||||||
|
.client
|
||||||
|
.put_object()
|
||||||
|
.bucket(&self.bucket)
|
||||||
|
.key(key)
|
||||||
|
.body(ByteStream::from(bytes));
|
||||||
|
|
||||||
|
if let Some(content_type) = content_type {
|
||||||
|
request = request.content_type(content_type);
|
||||||
|
}
|
||||||
|
|
||||||
|
request
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.context("failed to upload object to S3")?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn presign_get_object(&self, key: &str, expires_in: Duration) -> Result<String> {
|
||||||
|
let presign_config = PresigningConfig::builder()
|
||||||
|
.expires_in(expires_in)
|
||||||
|
.build()
|
||||||
|
.context("failed to build S3 presigning config")?;
|
||||||
|
|
||||||
|
let presigned = self
|
||||||
|
.client
|
||||||
|
.get_object()
|
||||||
|
.bucket(&self.bucket)
|
||||||
|
.key(key)
|
||||||
|
.presigned(presign_config)
|
||||||
|
.await
|
||||||
|
.context("failed to generate presigned download URL")?;
|
||||||
|
|
||||||
|
Ok(presigned.uri().to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
mod common;
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use axum::http::StatusCode;
|
||||||
|
use common::{acquire_db_lock, body_to_vec, TestApp};
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct AuthenticatedUser {
|
||||||
|
username: String,
|
||||||
|
role: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn login_and_me_roundtrip() -> Result<()> {
|
||||||
|
let _lock = acquire_db_lock().await;
|
||||||
|
let Some(app) = TestApp::new().await? else {
|
||||||
|
eprintln!("skipping test: TEST_DATABASE_URL not set");
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
|
||||||
|
let password = "s3cret";
|
||||||
|
app.insert_user("alice", password, "admin").await?;
|
||||||
|
|
||||||
|
let token = app.login_token("alice", password).await?;
|
||||||
|
|
||||||
|
let response = app.get("/api/auth/me", Some(&token)).await?;
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
let body = body_to_vec(response.into_body()).await?;
|
||||||
|
let user: AuthenticatedUser = serde_json::from_slice(&body)?;
|
||||||
|
|
||||||
|
assert_eq!(user.username, "alice");
|
||||||
|
assert_eq!(user.role, "admin");
|
||||||
|
|
||||||
|
app.cleanup().await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,392 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
use std::env;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use anyhow::{anyhow, ensure, Context, Result};
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use axum::body::Body;
|
||||||
|
use axum::http::{Method, Request, StatusCode};
|
||||||
|
use axum::Router;
|
||||||
|
use diesel::connection::SimpleConnection;
|
||||||
|
use diesel::prelude::*;
|
||||||
|
use diesel::PgConnection;
|
||||||
|
use diesel_migrations::{embed_migrations, EmbeddedMigrations, MigrationHarness};
|
||||||
|
use http_body_util::BodyExt;
|
||||||
|
use once_cell::sync::Lazy;
|
||||||
|
use paperless_backend::auth::jwt::JwtService;
|
||||||
|
use paperless_backend::config::AppConfig;
|
||||||
|
use paperless_backend::db::{self, PgPool};
|
||||||
|
use paperless_backend::models::NewUser;
|
||||||
|
use paperless_backend::routes;
|
||||||
|
use paperless_backend::state::AppState;
|
||||||
|
use paperless_backend::storage::ObjectStorage;
|
||||||
|
use rand::rngs::OsRng;
|
||||||
|
use serde::Serialize;
|
||||||
|
use tokio::sync::Mutex;
|
||||||
|
use tower::util::ServiceExt;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
const MIGRATIONS: EmbeddedMigrations = embed_migrations!("migrations");
|
||||||
|
|
||||||
|
static DB_LOCK: Lazy<Mutex<()>> = Lazy::new(|| Mutex::new(()));
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct StoredObject {
|
||||||
|
pub key: String,
|
||||||
|
pub bytes: Vec<u8>,
|
||||||
|
pub content_type: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct FakeStorage {
|
||||||
|
objects: Mutex<HashMap<String, StoredObject>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl ObjectStorage for FakeStorage {
|
||||||
|
async fn put_object(
|
||||||
|
&self,
|
||||||
|
key: &str,
|
||||||
|
bytes: Vec<u8>,
|
||||||
|
content_type: Option<String>,
|
||||||
|
) -> Result<()> {
|
||||||
|
let stored = StoredObject {
|
||||||
|
key: key.to_string(),
|
||||||
|
bytes,
|
||||||
|
content_type,
|
||||||
|
};
|
||||||
|
let mut guard = self.objects.lock().await;
|
||||||
|
guard.insert(stored.key.clone(), stored);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn presign_get_object(&self, key: &str, expires_in: Duration) -> Result<String> {
|
||||||
|
let guard = self.objects.lock().await;
|
||||||
|
ensure!(guard.contains_key(key), "object {key} missing");
|
||||||
|
Ok(format!(
|
||||||
|
"https://fake-storage/{key}?expires_in={}",
|
||||||
|
expires_in.as_secs()
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FakeStorage {
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub async fn get(&self, key: &str) -> Option<StoredObject> {
|
||||||
|
let guard = self.objects.lock().await;
|
||||||
|
guard.get(key).cloned()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub async fn object_count(&self) -> usize {
|
||||||
|
let guard = self.objects.lock().await;
|
||||||
|
guard.len()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct TestApp {
|
||||||
|
pub state: AppState,
|
||||||
|
router: Router,
|
||||||
|
storage: Arc<FakeStorage>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TestApp {
|
||||||
|
pub async fn new() -> Result<Option<Self>> {
|
||||||
|
let database_url = match env::var("TEST_DATABASE_URL") {
|
||||||
|
Ok(url) => url,
|
||||||
|
Err(_) => return Ok(None),
|
||||||
|
};
|
||||||
|
|
||||||
|
let config = AppConfig {
|
||||||
|
database_url: database_url.clone(),
|
||||||
|
server_host: "127.0.0.1".to_string(),
|
||||||
|
server_port: 0,
|
||||||
|
jwt_secret: "test-secret".to_string(),
|
||||||
|
jwt_issuer: "test-issuer".to_string(),
|
||||||
|
jwt_audience: "test-audience".to_string(),
|
||||||
|
jwt_expiry_minutes: 60,
|
||||||
|
aws_endpoint_url: None,
|
||||||
|
aws_access_key_id: None,
|
||||||
|
aws_secret_access_key: None,
|
||||||
|
aws_region: "us-east-1".to_string(),
|
||||||
|
s3_bucket: "test-bucket".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let pool = db::init_pool(&config.database_url)?;
|
||||||
|
prepare_database(&pool).await?;
|
||||||
|
|
||||||
|
let storage = Arc::new(FakeStorage::default());
|
||||||
|
let storage_for_state: Arc<dyn ObjectStorage> = storage.clone();
|
||||||
|
let jwt = JwtService::from_config(&config)?;
|
||||||
|
let state = AppState::new(pool.clone(), config, storage_for_state, jwt);
|
||||||
|
let router = routes::create_router(state.clone());
|
||||||
|
|
||||||
|
Ok(Some(Self {
|
||||||
|
state,
|
||||||
|
router,
|
||||||
|
storage,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn cleanup(&self) -> Result<()> {
|
||||||
|
let pool = self.state.pool.clone();
|
||||||
|
tokio::task::spawn_blocking(move || -> Result<()> {
|
||||||
|
let mut conn = pool
|
||||||
|
.get()
|
||||||
|
.map_err(|err| anyhow!("failed to get cleanup connection: {err}"))?;
|
||||||
|
truncate_all(&mut conn)?;
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.context("cleanup task panicked")?
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn storage(&self) -> Arc<FakeStorage> {
|
||||||
|
self.storage.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn insert_user(&self, username: &str, password: &str, role: &str) -> Result<Uuid> {
|
||||||
|
let username = username.to_string();
|
||||||
|
let password = password.to_string();
|
||||||
|
let role = role.to_string();
|
||||||
|
self.with_conn(move |conn| {
|
||||||
|
let password_hash = hash_password(&password)?;
|
||||||
|
let user = NewUser {
|
||||||
|
id: Uuid::new_v4(),
|
||||||
|
username,
|
||||||
|
password_hash,
|
||||||
|
role,
|
||||||
|
};
|
||||||
|
diesel::insert_into(paperless_backend::schema::users::table)
|
||||||
|
.values(&user)
|
||||||
|
.execute(conn)
|
||||||
|
.context("failed to insert user")?;
|
||||||
|
Ok(user.id)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn login_token(&self, username: &str, password: &str) -> Result<String> {
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct LoginPayload<'a> {
|
||||||
|
username: &'a str,
|
||||||
|
password: &'a str,
|
||||||
|
}
|
||||||
|
|
||||||
|
let response = self
|
||||||
|
.post_json(
|
||||||
|
"/api/auth/login",
|
||||||
|
&LoginPayload { username, password },
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
ensure!(
|
||||||
|
response.status() == StatusCode::OK,
|
||||||
|
"login failed with status {}",
|
||||||
|
response.status()
|
||||||
|
);
|
||||||
|
|
||||||
|
let body = body_to_vec(response.into_body()).await?;
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct LoginResponse {
|
||||||
|
access_token: String,
|
||||||
|
}
|
||||||
|
let parsed: LoginResponse = serde_json::from_slice(&body)?;
|
||||||
|
Ok(parsed.access_token)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn post_json<T: Serialize + ?Sized>(
|
||||||
|
&self,
|
||||||
|
path: &str,
|
||||||
|
payload: &T,
|
||||||
|
token: Option<&str>,
|
||||||
|
) -> Result<hyper::Response<Body>> {
|
||||||
|
let body = serde_json::to_vec(payload)?;
|
||||||
|
let mut builder = Request::builder()
|
||||||
|
.method(Method::POST)
|
||||||
|
.uri(path)
|
||||||
|
.header("content-type", "application/json");
|
||||||
|
if let Some(token) = token {
|
||||||
|
builder = builder.header("authorization", format!("Bearer {token}"));
|
||||||
|
}
|
||||||
|
let request = builder.body(Body::from(body))?;
|
||||||
|
Ok(self
|
||||||
|
.router
|
||||||
|
.clone()
|
||||||
|
.oneshot(request)
|
||||||
|
.await
|
||||||
|
.expect("infallible response"))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub async fn patch_json<T: Serialize + ?Sized>(
|
||||||
|
&self,
|
||||||
|
path: &str,
|
||||||
|
payload: &T,
|
||||||
|
token: Option<&str>,
|
||||||
|
) -> Result<hyper::Response<Body>> {
|
||||||
|
let body = serde_json::to_vec(payload)?;
|
||||||
|
let mut builder = Request::builder()
|
||||||
|
.method(Method::PATCH)
|
||||||
|
.uri(path)
|
||||||
|
.header("content-type", "application/json");
|
||||||
|
if let Some(token) = token {
|
||||||
|
builder = builder.header("authorization", format!("Bearer {token}"));
|
||||||
|
}
|
||||||
|
let request = builder.body(Body::from(body))?;
|
||||||
|
Ok(self
|
||||||
|
.router
|
||||||
|
.clone()
|
||||||
|
.oneshot(request)
|
||||||
|
.await
|
||||||
|
.expect("infallible response"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get(&self, path: &str, token: Option<&str>) -> Result<hyper::Response<Body>> {
|
||||||
|
let mut builder = Request::builder().method(Method::GET).uri(path);
|
||||||
|
if let Some(token) = token {
|
||||||
|
builder = builder.header("authorization", format!("Bearer {token}"));
|
||||||
|
}
|
||||||
|
let request = builder.body(Body::empty())?;
|
||||||
|
Ok(self
|
||||||
|
.router
|
||||||
|
.clone()
|
||||||
|
.oneshot(request)
|
||||||
|
.await
|
||||||
|
.expect("infallible response"))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub async fn delete(&self, path: &str, token: Option<&str>) -> Result<hyper::Response<Body>> {
|
||||||
|
let builder = Request::builder().method(Method::DELETE).uri(path);
|
||||||
|
let builder = if let Some(token) = token {
|
||||||
|
builder.header("authorization", format!("Bearer {token}"))
|
||||||
|
} else {
|
||||||
|
builder
|
||||||
|
};
|
||||||
|
let request = builder.body(Body::empty())?;
|
||||||
|
Ok(self
|
||||||
|
.router
|
||||||
|
.clone()
|
||||||
|
.oneshot(request)
|
||||||
|
.await
|
||||||
|
.expect("infallible response"))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub async fn upload_document(
|
||||||
|
&self,
|
||||||
|
path: &str,
|
||||||
|
filename: &str,
|
||||||
|
content_type: &str,
|
||||||
|
data: &[u8],
|
||||||
|
folder_id: Option<Uuid>,
|
||||||
|
token: &str,
|
||||||
|
) -> Result<hyper::Response<Body>> {
|
||||||
|
let boundary = format!("boundary-{}", Uuid::new_v4());
|
||||||
|
let mut body = Vec::new();
|
||||||
|
body.extend(format!("--{boundary}\r\n").as_bytes());
|
||||||
|
body.extend(
|
||||||
|
format!(
|
||||||
|
"Content-Disposition: form-data; name=\"file\"; filename=\"{}\"\r\n",
|
||||||
|
filename
|
||||||
|
)
|
||||||
|
.as_bytes(),
|
||||||
|
);
|
||||||
|
body.extend(format!("Content-Type: {}\r\n\r\n", content_type).as_bytes());
|
||||||
|
body.extend(data);
|
||||||
|
body.extend(b"\r\n");
|
||||||
|
|
||||||
|
if let Some(folder) = folder_id {
|
||||||
|
body.extend(format!("--{boundary}\r\n").as_bytes());
|
||||||
|
body.extend(b"Content-Disposition: form-data; name=\"folder_id\"\r\n\r\n");
|
||||||
|
body.extend(folder.to_string().as_bytes());
|
||||||
|
body.extend(b"\r\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
body.extend(format!("--{boundary}--\r\n").as_bytes());
|
||||||
|
|
||||||
|
let builder = Request::builder()
|
||||||
|
.method(Method::POST)
|
||||||
|
.uri(path)
|
||||||
|
.header(
|
||||||
|
"content-type",
|
||||||
|
format!("multipart/form-data; boundary={boundary}"),
|
||||||
|
)
|
||||||
|
.header("authorization", format!("Bearer {token}"));
|
||||||
|
|
||||||
|
let request = builder.body(Body::from(body))?;
|
||||||
|
Ok(self
|
||||||
|
.router
|
||||||
|
.clone()
|
||||||
|
.oneshot(request)
|
||||||
|
.await
|
||||||
|
.expect("infallible response"))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn with_conn<F, T>(&self, f: F) -> Result<T>
|
||||||
|
where
|
||||||
|
F: FnOnce(&mut PgConnection) -> Result<T> + Send + 'static,
|
||||||
|
T: Send + 'static,
|
||||||
|
{
|
||||||
|
let pool = self.state.pool.clone();
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
let mut conn = pool
|
||||||
|
.get()
|
||||||
|
.map_err(|err| anyhow!("failed to get database connection: {err}"))?;
|
||||||
|
f(&mut conn)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.context("connection task panicked")?
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn acquire_db_lock() -> tokio::sync::MutexGuard<'static, ()> {
|
||||||
|
DB_LOCK.lock().await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn body_to_vec(body: Body) -> Result<Vec<u8>> {
|
||||||
|
let collected = body
|
||||||
|
.collect()
|
||||||
|
.await
|
||||||
|
.map_err(|err| anyhow!("failed to read response body: {err}"))?;
|
||||||
|
Ok(collected.to_bytes().to_vec())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn prepare_database(pool: &PgPool) -> Result<()> {
|
||||||
|
let pool = pool.clone();
|
||||||
|
tokio::task::spawn_blocking(move || -> Result<()> {
|
||||||
|
let mut conn = pool
|
||||||
|
.get()
|
||||||
|
.map_err(|err| anyhow!("failed to acquire connection: {err}"))?;
|
||||||
|
conn.run_pending_migrations(MIGRATIONS)
|
||||||
|
.map_err(|err| anyhow!("failed to run migrations: {err}"))?;
|
||||||
|
truncate_all(&mut conn)?;
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.context("migration task panicked")?
|
||||||
|
}
|
||||||
|
|
||||||
|
fn truncate_all(conn: &mut PgConnection) -> Result<()> {
|
||||||
|
conn.batch_execute(
|
||||||
|
"TRUNCATE TABLE document_tags, document_versions, documents, folders, tags, users RESTART IDENTITY CASCADE;",
|
||||||
|
)
|
||||||
|
.context("failed to truncate tables")?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hash_password(password: &str) -> Result<String> {
|
||||||
|
use argon2::password_hash::{PasswordHasher, SaltString};
|
||||||
|
use argon2::Argon2;
|
||||||
|
|
||||||
|
let salt = SaltString::generate(&mut OsRng);
|
||||||
|
Ok(Argon2::default()
|
||||||
|
.hash_password(password.as_bytes(), &salt)
|
||||||
|
.map_err(|err| anyhow!("failed to hash password: {err}"))?
|
||||||
|
.to_string())
|
||||||
|
}
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
mod common;
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use axum::http::StatusCode;
|
||||||
|
use common::{acquire_db_lock, body_to_vec, TestApp};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use serde_json::Value;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct DocumentDetail {
|
||||||
|
document: DocumentInfo,
|
||||||
|
current_version: DocumentVersion,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct DocumentInfo {
|
||||||
|
id: Uuid,
|
||||||
|
original_name: String,
|
||||||
|
current_version: i32,
|
||||||
|
deleted_at: Option<String>,
|
||||||
|
tags: Vec<Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct DocumentVersion {
|
||||||
|
s3_key: String,
|
||||||
|
size_bytes: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct DocumentListItem {
|
||||||
|
id: Uuid,
|
||||||
|
current_version: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct DocumentDownload {
|
||||||
|
url: String,
|
||||||
|
filename: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn upload_and_list_document() -> Result<()> {
|
||||||
|
let _lock = acquire_db_lock().await;
|
||||||
|
let Some(app) = TestApp::new().await? else {
|
||||||
|
eprintln!("skipping test: TEST_DATABASE_URL not set");
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
|
||||||
|
let password = "passw0rd";
|
||||||
|
app.insert_user("dana", password, "admin").await?;
|
||||||
|
let token = app.login_token("dana", password).await?;
|
||||||
|
|
||||||
|
let file_bytes = b"example document body".to_vec();
|
||||||
|
let upload = app
|
||||||
|
.upload_document(
|
||||||
|
"/api/documents",
|
||||||
|
"doc.txt",
|
||||||
|
"text/plain",
|
||||||
|
&file_bytes,
|
||||||
|
None,
|
||||||
|
&token,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
assert_eq!(upload.status(), StatusCode::OK);
|
||||||
|
let body = body_to_vec(upload.into_body()).await?;
|
||||||
|
let detail: DocumentDetail = serde_json::from_slice(&body)?;
|
||||||
|
|
||||||
|
assert_eq!(detail.document.original_name, "doc.txt");
|
||||||
|
assert_eq!(detail.document.current_version, 1);
|
||||||
|
assert_eq!(detail.document.deleted_at, None);
|
||||||
|
assert!(detail.document.tags.is_empty());
|
||||||
|
assert_eq!(detail.current_version.size_bytes, file_bytes.len() as i64);
|
||||||
|
|
||||||
|
let stored = app
|
||||||
|
.storage()
|
||||||
|
.get(&detail.current_version.s3_key)
|
||||||
|
.await
|
||||||
|
.expect("object stored");
|
||||||
|
assert_eq!(stored.bytes, file_bytes);
|
||||||
|
|
||||||
|
let response = app.get("/api/documents", Some(&token)).await?;
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
let body = body_to_vec(response.into_body()).await?;
|
||||||
|
let mut list: Vec<DocumentListItem> = serde_json::from_slice(&body)?;
|
||||||
|
assert_eq!(list.len(), 1);
|
||||||
|
let item = list.pop().unwrap();
|
||||||
|
assert_eq!(item.id, detail.document.id);
|
||||||
|
assert_eq!(item.current_version, 1);
|
||||||
|
|
||||||
|
let download = app
|
||||||
|
.get(
|
||||||
|
&format!("/api/documents/{}/download", detail.document.id),
|
||||||
|
Some(&token),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
assert_eq!(download.status(), StatusCode::OK);
|
||||||
|
let body = body_to_vec(download.into_body()).await?;
|
||||||
|
let download_info: DocumentDownload = serde_json::from_slice(&body)?;
|
||||||
|
assert!(download_info.url.contains(&detail.current_version.s3_key));
|
||||||
|
assert_eq!(download_info.filename, "doc.txt");
|
||||||
|
|
||||||
|
app.cleanup().await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn duplicate_and_restore_document() -> Result<()> {
|
||||||
|
let _lock = acquire_db_lock().await;
|
||||||
|
let Some(app) = TestApp::new().await? else {
|
||||||
|
eprintln!("skipping test: TEST_DATABASE_URL not set");
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
|
||||||
|
let password = "pass1234";
|
||||||
|
app.insert_user("sam", password, "admin").await?;
|
||||||
|
let token = app.login_token("sam", password).await?;
|
||||||
|
|
||||||
|
let payload = b"same bytes".to_vec();
|
||||||
|
let first = app
|
||||||
|
.upload_document(
|
||||||
|
"/api/documents",
|
||||||
|
"dup.bin",
|
||||||
|
"application/octet-stream",
|
||||||
|
&payload,
|
||||||
|
None,
|
||||||
|
&token,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let first_body = body_to_vec(first.into_body()).await?;
|
||||||
|
let first_detail: DocumentDetail = serde_json::from_slice(&first_body)?;
|
||||||
|
|
||||||
|
let second = app
|
||||||
|
.upload_document(
|
||||||
|
"/api/documents",
|
||||||
|
"dup.bin",
|
||||||
|
"application/octet-stream",
|
||||||
|
&payload,
|
||||||
|
None,
|
||||||
|
&token,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
assert_eq!(second.status(), StatusCode::OK);
|
||||||
|
let second_body = body_to_vec(second.into_body()).await?;
|
||||||
|
let second_detail: DocumentDetail = serde_json::from_slice(&second_body)?;
|
||||||
|
|
||||||
|
assert_eq!(first_detail.document.id, second_detail.document.id);
|
||||||
|
assert_eq!(second_detail.document.deleted_at, None);
|
||||||
|
|
||||||
|
let delete = app
|
||||||
|
.delete(
|
||||||
|
&format!("/api/documents/{}", first_detail.document.id),
|
||||||
|
Some(&token),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
assert_eq!(delete.status(), StatusCode::NO_CONTENT);
|
||||||
|
|
||||||
|
let third = app
|
||||||
|
.upload_document(
|
||||||
|
"/api/documents",
|
||||||
|
"dup.bin",
|
||||||
|
"application/octet-stream",
|
||||||
|
&payload,
|
||||||
|
None,
|
||||||
|
&token,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let third_body = body_to_vec(third.into_body()).await?;
|
||||||
|
let third_detail: DocumentDetail = serde_json::from_slice(&third_body)?;
|
||||||
|
|
||||||
|
assert_eq!(third_detail.document.id, first_detail.document.id);
|
||||||
|
assert_eq!(third_detail.document.deleted_at, None);
|
||||||
|
|
||||||
|
app.cleanup().await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
mod common;
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use axum::http::StatusCode;
|
||||||
|
use common::{acquire_db_lock, body_to_vec, TestApp};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use serde::Serialize;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct FolderResponse {
|
||||||
|
folder: FolderInfo,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct FolderInfo {
|
||||||
|
id: Uuid,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct FolderContents {
|
||||||
|
documents: Vec<DocSummary>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct DocSummary {
|
||||||
|
id: Uuid,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct CreateFolder<'a> {
|
||||||
|
name: &'a str,
|
||||||
|
parent_id: Option<Uuid>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct MoveDocumentRequest {
|
||||||
|
folder_id: Option<Uuid>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct DocumentDetail {
|
||||||
|
document: DocSummary,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn folder_move_and_delete_flow() -> Result<()> {
|
||||||
|
let _lock = acquire_db_lock().await;
|
||||||
|
let Some(app) = TestApp::new().await? else {
|
||||||
|
eprintln!("skipping test: TEST_DATABASE_URL not set");
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
|
||||||
|
let password = "folderpass";
|
||||||
|
app.insert_user("folder-admin", password, "admin").await?;
|
||||||
|
let token = app.login_token("folder-admin", password).await?;
|
||||||
|
|
||||||
|
let folder_resp = app
|
||||||
|
.post_json(
|
||||||
|
"/api/folders",
|
||||||
|
&CreateFolder {
|
||||||
|
name: "Projects",
|
||||||
|
parent_id: None,
|
||||||
|
},
|
||||||
|
Some(&token),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
assert_eq!(folder_resp.status(), StatusCode::OK);
|
||||||
|
let folder_body = body_to_vec(folder_resp.into_body()).await?;
|
||||||
|
let folder: FolderResponse = serde_json::from_slice(&folder_body)?;
|
||||||
|
|
||||||
|
let upload = app
|
||||||
|
.upload_document(
|
||||||
|
"/api/documents",
|
||||||
|
"plan.pdf",
|
||||||
|
"application/pdf",
|
||||||
|
b"dummy",
|
||||||
|
None,
|
||||||
|
&token,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let upload_body = body_to_vec(upload.into_body()).await?;
|
||||||
|
let detail: DocumentDetail = serde_json::from_slice(&upload_body)?;
|
||||||
|
|
||||||
|
let move_resp = app
|
||||||
|
.patch_json(
|
||||||
|
&format!("/api/documents/{}/folder", detail.document.id),
|
||||||
|
&MoveDocumentRequest {
|
||||||
|
folder_id: Some(folder.folder.id),
|
||||||
|
},
|
||||||
|
Some(&token),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
assert_eq!(move_resp.status(), StatusCode::NO_CONTENT);
|
||||||
|
|
||||||
|
let contents = app
|
||||||
|
.get(
|
||||||
|
&format!("/api/folders/{}/contents", folder.folder.id),
|
||||||
|
Some(&token),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
assert_eq!(contents.status(), StatusCode::OK);
|
||||||
|
let contents_body = body_to_vec(contents.into_body()).await?;
|
||||||
|
let contents: FolderContents = serde_json::from_slice(&contents_body)?;
|
||||||
|
assert_eq!(contents.documents.len(), 1);
|
||||||
|
assert_eq!(contents.documents[0].id, detail.document.id);
|
||||||
|
|
||||||
|
let root_contents = app.get("/api/folders/root/contents", Some(&token)).await?;
|
||||||
|
let root_body = body_to_vec(root_contents.into_body()).await?;
|
||||||
|
let root: FolderContents = serde_json::from_slice(&root_body)?;
|
||||||
|
assert!(root
|
||||||
|
.documents
|
||||||
|
.iter()
|
||||||
|
.all(|doc| doc.id != detail.document.id));
|
||||||
|
|
||||||
|
let delete_attempt = app
|
||||||
|
.delete(&format!("/api/folders/{}", folder.folder.id), Some(&token))
|
||||||
|
.await?;
|
||||||
|
assert_eq!(delete_attempt.status(), StatusCode::BAD_REQUEST);
|
||||||
|
|
||||||
|
let move_back = app
|
||||||
|
.patch_json(
|
||||||
|
&format!("/api/documents/{}/folder", detail.document.id),
|
||||||
|
&MoveDocumentRequest { folder_id: None },
|
||||||
|
Some(&token),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
assert_eq!(move_back.status(), StatusCode::NO_CONTENT);
|
||||||
|
|
||||||
|
let delete = app
|
||||||
|
.delete(&format!("/api/folders/{}", folder.folder.id), Some(&token))
|
||||||
|
.await?;
|
||||||
|
assert_eq!(delete.status(), StatusCode::NO_CONTENT);
|
||||||
|
|
||||||
|
app.cleanup().await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
mod common;
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use axum::http::StatusCode;
|
||||||
|
use common::{acquire_db_lock, body_to_vec, TestApp};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use serde::Serialize;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct DocumentDetail {
|
||||||
|
document: DocumentInfo,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct DocumentInfo {
|
||||||
|
id: Uuid,
|
||||||
|
tags: Vec<TagInfo>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct TagInfo {
|
||||||
|
label: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct TagResponse {
|
||||||
|
id: Uuid,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct AssignTagsRequest {
|
||||||
|
tag_ids: Vec<Uuid>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn tag_assignment_flow() -> Result<()> {
|
||||||
|
let _lock = acquire_db_lock().await;
|
||||||
|
let Some(app) = TestApp::new().await? else {
|
||||||
|
eprintln!("skipping test: TEST_DATABASE_URL not set");
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
|
||||||
|
let password = "tagpass";
|
||||||
|
app.insert_user("tagger", password, "admin").await?;
|
||||||
|
let token = app.login_token("tagger", password).await?;
|
||||||
|
|
||||||
|
let upload = app
|
||||||
|
.upload_document(
|
||||||
|
"/api/documents",
|
||||||
|
"tagged.txt",
|
||||||
|
"text/plain",
|
||||||
|
b"tag me",
|
||||||
|
None,
|
||||||
|
&token,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let upload_body = body_to_vec(upload.into_body()).await?;
|
||||||
|
let detail: DocumentDetail = serde_json::from_slice(&upload_body)?;
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct CreateTagPayload<'a> {
|
||||||
|
label: &'a str,
|
||||||
|
color: Option<&'a str>,
|
||||||
|
}
|
||||||
|
|
||||||
|
let create_tag = app
|
||||||
|
.post_json(
|
||||||
|
"/api/tags",
|
||||||
|
&CreateTagPayload {
|
||||||
|
label: "Important",
|
||||||
|
color: Some("#FF0000"),
|
||||||
|
},
|
||||||
|
Some(&token),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
assert_eq!(create_tag.status(), StatusCode::OK);
|
||||||
|
let body = body_to_vec(create_tag.into_body()).await?;
|
||||||
|
let tag: TagResponse = serde_json::from_slice(&body)?;
|
||||||
|
|
||||||
|
let assign = app
|
||||||
|
.post_json(
|
||||||
|
&format!("/api/documents/{}/tags", detail.document.id),
|
||||||
|
&AssignTagsRequest {
|
||||||
|
tag_ids: vec![tag.id],
|
||||||
|
},
|
||||||
|
Some(&token),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
assert_eq!(assign.status(), StatusCode::NO_CONTENT);
|
||||||
|
|
||||||
|
let refreshed = app
|
||||||
|
.get(
|
||||||
|
&format!("/api/documents/{}", detail.document.id),
|
||||||
|
Some(&token),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
assert_eq!(refreshed.status(), StatusCode::OK);
|
||||||
|
let refreshed_body = body_to_vec(refreshed.into_body()).await?;
|
||||||
|
let refreshed_detail: DocumentDetail = serde_json::from_slice(&refreshed_body)?;
|
||||||
|
assert_eq!(refreshed_detail.document.tags.len(), 1);
|
||||||
|
assert_eq!(refreshed_detail.document.tags[0].label, "Important");
|
||||||
|
|
||||||
|
let remove = app
|
||||||
|
.delete(
|
||||||
|
&format!("/api/documents/{}/tags/{}", detail.document.id, tag.id),
|
||||||
|
Some(&token),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
assert_eq!(remove.status(), StatusCode::NO_CONTENT);
|
||||||
|
|
||||||
|
let final_check = app
|
||||||
|
.get(
|
||||||
|
&format!("/api/documents/{}", detail.document.id),
|
||||||
|
Some(&token),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let final_body = body_to_vec(final_check.into_body()).await?;
|
||||||
|
let final_detail: DocumentDetail = serde_json::from_slice(&final_body)?;
|
||||||
|
assert!(final_detail.document.tags.is_empty());
|
||||||
|
|
||||||
|
app.cleanup().await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user