Initial commit
ci / docker (backend, backend/Dockerfile, backend) (push) Successful in 15m15s
ci / docker (frontend, frontend/Dockerfile, frontend) (push) Successful in 15m14s

This commit is contained in:
2025-10-17 18:07:39 +02:00
parent 768f8cb21c
commit 2713dcb047
123 changed files with 25116 additions and 1755 deletions
+34
View File
@@ -0,0 +1,34 @@
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 app = TestApp::new().await?;
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(())
}
+444
View File
@@ -0,0 +1,444 @@
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 backend::auth::jwt::JwtService;
use backend::config::AppConfig;
use backend::db::{self, PgPool};
use backend::models::{Job, NewUser};
use backend::routes;
use backend::state::AppState;
use backend::storage::ObjectStorage;
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 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>,
pub content_disposition: 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>,
content_disposition: Option<String>,
) -> Result<()> {
let stored = StoredObject {
key: key.to_string(),
bytes,
content_type,
content_disposition,
};
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()
))
}
async fn get_object(&self, key: &str) -> Result<Vec<u8>> {
let guard = self.objects.lock().await;
guard
.get(key)
.map(|obj| obj.bytes.clone())
.ok_or_else(|| anyhow!("object {key} missing"))
}
async fn delete_object(&self, key: &str) -> Result<()> {
let mut guard = self.objects.lock().await;
guard.remove(key);
Ok(())
}
}
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<Self> {
let database_url = env::var("TEST_DATABASE_URL")
.context("TEST_DATABASE_URL must be set for integration tests")?;
let config = AppConfig {
database_url: database_url.clone(),
database_max_pool_size: db::DEFAULT_MAX_POOL_SIZE,
server_host: "127.0.0.1".to_string(),
server_port: 0,
webdav_host: "127.0.0.1".to_string(),
webdav_port: 0,
jwt_secret: "test-secret".to_string(),
jwt_issuer: "test-issuer".to_string(),
jwt_audience: "test-audience".to_string(),
jwt_expiry_minutes: 60,
download_token_audience: "test-download".to_string(),
download_token_expiry_minutes: 60,
refresh_token_expiry_days: 30,
refresh_cookie_secure: false,
refresh_cookie_domain: None,
cors_allowed_origin: None,
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(),
quickwit_endpoint: None,
quickwit_index: None,
};
let pool = db::init_pool_with_size(&config.database_url, config.database_max_pool_size)?;
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(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(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)
}
#[allow(dead_code)]
pub async fn clear_jobs(&self) -> Result<()> {
self.with_conn(|conn| {
use backend::schema::jobs::dsl::jobs as jobs_table;
diesel::delete(jobs_table)
.execute(conn)
.context("failed to clear jobs")?;
Ok(())
})
.await
}
#[allow(dead_code)]
pub async fn jobs_by_type(&self, ty: &str) -> Result<Vec<Job>> {
let ty = ty.to_string();
self.with_conn(move |conn| {
use backend::schema::jobs::dsl::{job_type as job_type_col, jobs as jobs_table};
let rows = jobs_table
.filter(job_type_col.eq(&ty))
.load::<Job>(conn)
.context("failed to load jobs")?;
Ok(rows)
})
.await
}
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())
}
+975
View File
@@ -0,0 +1,975 @@
mod common;
use anyhow::Result;
use axum::http::StatusCode;
use common::{acquire_db_lock, body_to_vec, TestApp};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Deserialize)]
struct DocumentDetail {
document: DocumentInfo,
}
#[derive(Deserialize)]
struct DocumentInfo {
id: Uuid,
title: String,
original_name: String,
deleted_at: Option<String>,
issued_at: Option<String>,
tags: Vec<TagSummary>,
#[serde(default)]
correspondents: Vec<DocumentCorrespondentInfo>,
#[serde(default)]
current_version: Option<DocumentVersion>,
}
#[derive(Deserialize)]
struct DocumentVersion {
id: Uuid,
s3_key: String,
size_bytes: i64,
version_number: i32,
download_path: String,
#[serde(default)]
assets: Vec<DocumentAssetInfo>,
}
#[allow(dead_code)]
#[derive(Deserialize)]
struct DocumentAssetInfo {
id: Uuid,
asset_type: String,
}
#[derive(Deserialize)]
struct DocumentListItem {
id: Uuid,
#[serde(default)]
current_version: Option<DocumentVersion>,
}
#[derive(Deserialize)]
struct DocumentDownload {
url: String,
filename: String,
}
#[derive(Deserialize)]
struct BulkReanalyze {
queued: usize,
}
#[derive(Deserialize)]
struct BulkMoveResult {
updated: usize,
}
#[derive(Deserialize)]
struct BulkTagResult {
added: usize,
removed: usize,
}
#[derive(Deserialize)]
struct TagSummary {
label: String,
}
#[derive(Deserialize)]
struct DocumentCorrespondentInfo {
name: String,
role: String,
}
#[derive(Deserialize)]
struct CorrespondentSummary {
id: Uuid,
}
#[derive(Deserialize)]
struct BulkCorrespondentResult {
assigned: usize,
removed: usize,
}
#[derive(Deserialize)]
struct AnalyzeJobPayload {
document_id: Uuid,
document_version_id: Uuid,
#[serde(default)]
force: bool,
}
#[derive(Deserialize)]
struct FolderResponse {
folder: FolderInfo,
}
#[derive(Deserialize)]
struct FolderInfo {
id: Uuid,
}
#[derive(Deserialize)]
struct FolderContents {
documents: Vec<DocumentListItem>,
}
#[derive(Deserialize)]
struct TagResponse {
id: Uuid,
}
#[derive(Serialize)]
struct BulkMoveRequest<'a> {
document_ids: &'a [Uuid],
folder_id: Option<Uuid>,
}
#[derive(Serialize)]
struct BulkTagRequest<'a> {
document_ids: &'a [Uuid],
tag_ids: &'a [Uuid],
action: &'a str,
}
#[derive(Serialize)]
struct CreateFolderRequest<'a> {
name: &'a str,
parent_id: Option<Uuid>,
}
#[derive(Serialize)]
struct CreateTagPayload<'a> {
label: &'a str,
color: Option<&'a str>,
}
#[tokio::test]
async fn upload_and_list_document() -> Result<()> {
let _lock = acquire_db_lock().await;
let app = TestApp::new().await?;
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::CREATED);
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.title, "doc");
assert_eq!(detail.document.deleted_at, None);
assert!(detail.document.issued_at.is_none());
assert!(detail.document.tags.is_empty());
let current_version = detail
.document
.current_version
.as_ref()
.expect("current version detail");
assert!(current_version.download_path.starts_with("/download/"));
assert_eq!(current_version.version_number, 1);
assert_eq!(current_version.size_bytes, file_bytes.len() as i64);
assert!(current_version.assets.is_empty());
let stored = app
.storage()
.get(&current_version.s3_key)
.await
.expect("object stored");
assert_eq!(stored.bytes, file_bytes);
assert_eq!(app.storage().object_count().await, 1);
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
.as_ref()
.map(|version| version.version_number),
Some(1)
);
assert!(item
.current_version
.as_ref()
.expect("list current version")
.download_path
.starts_with("/download/"));
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(&current_version.s3_key));
assert_eq!(download_info.filename, "doc.txt");
let redirect = app.get(&current_version.download_path, None).await?;
assert_eq!(redirect.status(), StatusCode::TEMPORARY_REDIRECT);
let location = redirect
.headers()
.get("location")
.expect("redirect location header");
let location = location.to_str().expect("location header utf8");
assert!(location.contains(&current_version.s3_key));
app.cleanup().await?;
Ok(())
}
#[tokio::test]
async fn duplicate_and_restore_document() -> Result<()> {
let _lock = acquire_db_lock().await;
let app = TestApp::new().await?;
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?;
assert_eq!(first.status(), StatusCode::CREATED);
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);
assert!(second_detail
.document
.current_version
.as_ref()
.expect("second current version")
.assets
.is_empty());
assert_eq!(app.storage().object_count().await, 1);
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?;
assert_eq!(third.status(), StatusCode::OK);
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);
assert_eq!(app.storage().object_count().await, 1);
app.cleanup().await?;
Ok(())
}
#[tokio::test]
async fn bulk_reanalyze_documents() -> Result<()> {
let _lock = acquire_db_lock().await;
let app = TestApp::new().await?;
let password = "bulkpass";
app.insert_user("alex", password, "admin").await?;
let token = app.login_token("alex", password).await?;
app.clear_jobs().await?;
let first_bytes = b"first doc";
let first = app
.upload_document(
"/api/documents",
"first.txt",
"text/plain",
first_bytes,
None,
&token,
)
.await?;
assert_eq!(first.status(), StatusCode::CREATED);
let first_body = body_to_vec(first.into_body()).await?;
let first_detail: DocumentDetail = serde_json::from_slice(&first_body)?;
let second_bytes = b"second doc";
let second = app
.upload_document(
"/api/documents",
"second.txt",
"text/plain",
second_bytes,
None,
&token,
)
.await?;
assert_eq!(second.status(), StatusCode::CREATED);
let second_body = body_to_vec(second.into_body()).await?;
let second_detail: DocumentDetail = serde_json::from_slice(&second_body)?;
app.clear_jobs().await?;
let response = app
.post_json(
"/api/documents/reanalyze",
&serde_json::json!({}),
Some(&token),
)
.await?;
assert_eq!(response.status(), StatusCode::ACCEPTED);
let body = body_to_vec(response.into_body()).await?;
let bulk: BulkReanalyze = serde_json::from_slice(&body)?;
assert_eq!(bulk.queued, 2);
let jobs = app.jobs_by_type("analyze-document").await?;
assert_eq!(jobs.len(), 2);
let mut payload_docs = Vec::new();
for job in jobs {
let payload: AnalyzeJobPayload = serde_json::from_value(job.payload)?;
assert!(payload.force);
payload_docs.push((payload.document_id, payload.document_version_id));
}
let mut expected = vec![
(
first_detail.document.id,
first_detail
.document
.current_version
.as_ref()
.expect("first current version")
.id,
),
(
second_detail.document.id,
second_detail
.document
.current_version
.as_ref()
.expect("second current version")
.id,
),
];
payload_docs.sort();
expected.sort();
assert_eq!(payload_docs, expected);
app.cleanup().await?;
Ok(())
}
#[tokio::test]
async fn bulk_move_documents_to_folder() -> Result<()> {
let _lock = acquire_db_lock().await;
let app = TestApp::new().await?;
let password = "bulkmove";
app.insert_user("mover", password, "admin").await?;
let token = app.login_token("mover", password).await?;
let alpha = app
.upload_document(
"/api/documents",
"alpha.txt",
"text/plain",
b"alpha",
None,
&token,
)
.await?;
assert_eq!(alpha.status(), StatusCode::CREATED);
let alpha_body = body_to_vec(alpha.into_body()).await?;
let alpha_detail: DocumentDetail = serde_json::from_slice(&alpha_body)?;
let beta = app
.upload_document(
"/api/documents",
"beta.txt",
"text/plain",
b"beta",
None,
&token,
)
.await?;
assert_eq!(beta.status(), StatusCode::CREATED);
let beta_body = body_to_vec(beta.into_body()).await?;
let beta_detail: DocumentDetail = serde_json::from_slice(&beta_body)?;
let folder_resp = app
.post_json(
"/api/folders",
&CreateFolderRequest {
name: "Archives",
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 move_resp = app
.post_json(
"/api/documents/bulk/move",
&BulkMoveRequest {
document_ids: &[alpha_detail.document.id, beta_detail.document.id],
folder_id: Some(folder.folder.id),
},
Some(&token),
)
.await?;
assert_eq!(move_resp.status(), StatusCode::OK);
let move_body = body_to_vec(move_resp.into_body()).await?;
let result: BulkMoveResult = serde_json::from_slice(&move_body)?;
assert_eq!(result.updated, 2);
let folder_contents = app
.get(
&format!("/api/folders/{}/contents", folder.folder.id),
Some(&token),
)
.await?;
assert_eq!(folder_contents.status(), StatusCode::OK);
let folder_body = body_to_vec(folder_contents.into_body()).await?;
let folder_docs: FolderContents = serde_json::from_slice(&folder_body)?;
let moved_ids: Vec<_> = folder_docs.documents.iter().map(|doc| doc.id).collect();
assert!(moved_ids.contains(&alpha_detail.document.id));
assert!(moved_ids.contains(&beta_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_docs: FolderContents = serde_json::from_slice(&root_body)?;
assert!(root_docs
.documents
.iter()
.all(|doc| doc.id != alpha_detail.document.id && doc.id != beta_detail.document.id));
app.cleanup().await?;
Ok(())
}
#[tokio::test]
async fn bulk_update_tags_for_selection() -> Result<()> {
let _lock = acquire_db_lock().await;
let app = TestApp::new().await?;
let password = "bulktags";
app.insert_user("tagger", password, "admin").await?;
let token = app.login_token("tagger", password).await?;
let first = app
.upload_document(
"/api/documents",
"notes.txt",
"text/plain",
b"notes",
None,
&token,
)
.await?;
assert_eq!(first.status(), StatusCode::CREATED);
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",
"report.txt",
"text/plain",
b"report",
None,
&token,
)
.await?;
assert_eq!(second.status(), StatusCode::CREATED);
let second_body = body_to_vec(second.into_body()).await?;
let second_detail: DocumentDetail = serde_json::from_slice(&second_body)?;
let urgent_tag = app
.post_json(
"/api/tags",
&CreateTagPayload {
label: "Urgent",
color: None,
},
Some(&token),
)
.await?;
assert_eq!(urgent_tag.status(), StatusCode::OK);
let urgent_body = body_to_vec(urgent_tag.into_body()).await?;
let urgent: TagResponse = serde_json::from_slice(&urgent_body)?;
let review_tag = app
.post_json(
"/api/tags",
&CreateTagPayload {
label: "Review",
color: None,
},
Some(&token),
)
.await?;
assert_eq!(review_tag.status(), StatusCode::OK);
let review_body = body_to_vec(review_tag.into_body()).await?;
let review: TagResponse = serde_json::from_slice(&review_body)?;
let add_resp = app
.post_json(
"/api/documents/bulk/tags",
&BulkTagRequest {
document_ids: &[first_detail.document.id, second_detail.document.id],
tag_ids: &[urgent.id, review.id],
action: "add",
},
Some(&token),
)
.await?;
assert_eq!(add_resp.status(), StatusCode::OK);
let add_body = body_to_vec(add_resp.into_body()).await?;
let add_result: BulkTagResult = serde_json::from_slice(&add_body)?;
assert_eq!(add_result.added, 4);
for doc_id in [&first_detail.document.id, &second_detail.document.id] {
let refreshed = app
.get(&format!("/api/documents/{}", doc_id), Some(&token))
.await?;
assert_eq!(refreshed.status(), StatusCode::OK);
let refreshed_body = body_to_vec(refreshed.into_body()).await?;
let detail: DocumentDetail = serde_json::from_slice(&refreshed_body)?;
let labels: Vec<_> = detail
.document
.tags
.iter()
.map(|tag| tag.label.as_str())
.collect();
assert!(labels.contains(&"Urgent"));
assert!(labels.contains(&"Review"));
}
let remove_resp = app
.post_json(
"/api/documents/bulk/tags",
&BulkTagRequest {
document_ids: &[first_detail.document.id, second_detail.document.id],
tag_ids: &[urgent.id],
action: "remove",
},
Some(&token),
)
.await?;
assert_eq!(remove_resp.status(), StatusCode::OK);
let remove_body = body_to_vec(remove_resp.into_body()).await?;
let remove_result: BulkTagResult = serde_json::from_slice(&remove_body)?;
assert_eq!(remove_result.removed, 2);
for doc_id in [&first_detail.document.id, &second_detail.document.id] {
let refreshed = app
.get(&format!("/api/documents/{}", doc_id), Some(&token))
.await?;
let refreshed_body = body_to_vec(refreshed.into_body()).await?;
let detail: DocumentDetail = serde_json::from_slice(&refreshed_body)?;
let labels: Vec<_> = detail
.document
.tags
.iter()
.map(|tag| tag.label.as_str())
.collect();
assert!(!labels.contains(&"Urgent"));
assert!(labels.contains(&"Review"));
}
app.cleanup().await?;
Ok(())
}
#[tokio::test]
async fn bulk_assign_correspondents_to_selection() -> Result<()> {
let _lock = acquire_db_lock().await;
let app = TestApp::new().await?;
let password = "bulkcorresp";
app.insert_user("corra", password, "admin").await?;
let token = app.login_token("corra", password).await?;
let first = app
.upload_document(
"/api/documents",
"letter-one.txt",
"text/plain",
b"letter one",
None,
&token,
)
.await?;
assert_eq!(first.status(), StatusCode::CREATED);
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",
"letter-two.txt",
"text/plain",
b"letter two",
None,
&token,
)
.await?;
assert_eq!(second.status(), StatusCode::CREATED);
let second_body = body_to_vec(second.into_body()).await?;
let second_detail: DocumentDetail = serde_json::from_slice(&second_body)?;
let sender = app
.post_json(
"/api/correspondents",
&serde_json::json!({ "name": "Acme Corp" }),
Some(&token),
)
.await?;
assert_eq!(sender.status(), StatusCode::OK);
let sender_body = body_to_vec(sender.into_body()).await?;
let sender_summary: CorrespondentSummary = serde_json::from_slice(&sender_body)?;
let receiver = app
.post_json(
"/api/correspondents",
&serde_json::json!({ "name": "Bank Ltd" }),
Some(&token),
)
.await?;
assert_eq!(receiver.status(), StatusCode::OK);
let receiver_body = body_to_vec(receiver.into_body()).await?;
let receiver_summary: CorrespondentSummary = serde_json::from_slice(&receiver_body)?;
let assign_payload = serde_json::json!({
"document_ids": [
first_detail.document.id,
second_detail.document.id
],
"assignments": [
{
"correspondent_id": sender_summary.id,
"role": "sender"
},
{
"correspondent_id": receiver_summary.id,
"role": "receiver"
}
]
});
let assign_resp = app
.post_json(
"/api/documents/bulk/correspondents",
&assign_payload,
Some(&token),
)
.await?;
assert_eq!(assign_resp.status(), StatusCode::OK);
let assign_body = body_to_vec(assign_resp.into_body()).await?;
let assign_result: BulkCorrespondentResult = serde_json::from_slice(&assign_body)?;
assert_eq!(assign_result.assigned, 4);
assert_eq!(assign_result.removed, 0);
for doc_id in [first_detail.document.id, second_detail.document.id] {
let refreshed = app
.get(&format!("/api/documents/{doc_id}"), Some(&token))
.await?;
assert_eq!(refreshed.status(), StatusCode::OK);
let refreshed_body = body_to_vec(refreshed.into_body()).await?;
let detail: DocumentDetail = serde_json::from_slice(&refreshed_body)?;
assert_eq!(detail.document.correspondents.len(), 2);
assert!(detail
.document
.correspondents
.iter()
.any(|entry| entry.role == "sender" && entry.name == "Acme Corp"));
assert!(detail
.document
.correspondents
.iter()
.any(|entry| entry.role == "receiver" && entry.name == "Bank Ltd"));
}
let duplicate_resp = app
.post_json(
"/api/documents/bulk/correspondents",
&assign_payload,
Some(&token),
)
.await?;
assert_eq!(duplicate_resp.status(), StatusCode::OK);
let duplicate_body = body_to_vec(duplicate_resp.into_body()).await?;
let duplicate_result: BulkCorrespondentResult = serde_json::from_slice(&duplicate_body)?;
assert_eq!(duplicate_result.assigned, 0);
assert_eq!(duplicate_result.removed, 0);
let replacement = app
.post_json(
"/api/correspondents",
&serde_json::json!({ "name": "Charlie" }),
Some(&token),
)
.await?;
assert_eq!(replacement.status(), StatusCode::OK);
let replacement_body = body_to_vec(replacement.into_body()).await?;
let replacement_summary: CorrespondentSummary = serde_json::from_slice(&replacement_body)?;
let replace_payload = serde_json::json!({
"document_ids": [
first_detail.document.id,
second_detail.document.id
],
"assignments": [
{
"correspondent_id": replacement_summary.id,
"role": "sender"
}
]
});
let replace_resp = app
.post_json(
"/api/documents/bulk/correspondents",
&replace_payload,
Some(&token),
)
.await?;
assert_eq!(replace_resp.status(), StatusCode::OK);
let replace_body = body_to_vec(replace_resp.into_body()).await?;
let replace_result: BulkCorrespondentResult = serde_json::from_slice(&replace_body)?;
assert_eq!(replace_result.assigned, 2);
assert_eq!(replace_result.removed, 2);
for doc_id in [first_detail.document.id, second_detail.document.id] {
let refreshed = app
.get(&format!("/api/documents/{doc_id}"), Some(&token))
.await?;
let refreshed_body = body_to_vec(refreshed.into_body()).await?;
let detail: DocumentDetail = serde_json::from_slice(&refreshed_body)?;
assert_eq!(detail.document.correspondents.len(), 2);
assert!(detail
.document
.correspondents
.iter()
.any(|entry| entry.role == "sender" && entry.name == "Charlie"));
assert!(detail
.document
.correspondents
.iter()
.any(|entry| entry.role == "receiver" && entry.name == "Bank Ltd"));
}
let remove_payload = serde_json::json!({
"document_ids": [
first_detail.document.id,
second_detail.document.id
],
"assignments": [
{
"correspondent_id": receiver_summary.id,
"role": "receiver"
}
],
"action": "remove"
});
let remove_resp = app
.post_json(
"/api/documents/bulk/correspondents",
&remove_payload,
Some(&token),
)
.await?;
assert_eq!(remove_resp.status(), StatusCode::OK);
let remove_body = body_to_vec(remove_resp.into_body()).await?;
let remove_result: BulkCorrespondentResult = serde_json::from_slice(&remove_body)?;
assert_eq!(remove_result.assigned, 0);
assert_eq!(remove_result.removed, 2);
for doc_id in [first_detail.document.id, second_detail.document.id] {
let refreshed = app
.get(&format!("/api/documents/{doc_id}"), Some(&token))
.await?;
let refreshed_body = body_to_vec(refreshed.into_body()).await?;
let detail: DocumentDetail = serde_json::from_slice(&refreshed_body)?;
assert_eq!(detail.document.correspondents.len(), 1);
assert!(detail
.document
.correspondents
.iter()
.any(|entry| entry.role == "sender" && entry.name == "Charlie"));
assert!(!detail
.document
.correspondents
.iter()
.any(|entry| entry.role == "receiver"));
}
app.cleanup().await?;
Ok(())
}
#[tokio::test]
async fn bulk_reanalyze_selected_documents() -> Result<()> {
let _lock = acquire_db_lock().await;
let app = TestApp::new().await?;
let password = "subsetrean";
app.insert_user("subset", password, "admin").await?;
let token = app.login_token("subset", password).await?;
app.clear_jobs().await?;
let first = app
.upload_document(
"/api/documents",
"doc-one.txt",
"text/plain",
b"one",
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",
"doc-two.txt",
"text/plain",
b"two",
None,
&token,
)
.await?;
let second_body = body_to_vec(second.into_body()).await?;
let second_detail: DocumentDetail = serde_json::from_slice(&second_body)?;
let third = app
.upload_document(
"/api/documents",
"doc-three.txt",
"text/plain",
b"three",
None,
&token,
)
.await?;
let third_body = body_to_vec(third.into_body()).await?;
let third_detail: DocumentDetail = serde_json::from_slice(&third_body)?;
app.clear_jobs().await?;
let response = app
.post_json(
"/api/documents/bulk/reanalyze",
&serde_json::json!({
"document_ids": [
first_detail.document.id,
third_detail.document.id
],
"force": true
}),
Some(&token),
)
.await?;
assert_eq!(response.status(), StatusCode::ACCEPTED);
let body = body_to_vec(response.into_body()).await?;
let bulk: BulkReanalyze = serde_json::from_slice(&body)?;
assert_eq!(bulk.queued, 2);
let jobs = app.jobs_by_type("analyze-document").await?;
assert_eq!(jobs.len(), 2);
let mut payload_docs = Vec::new();
for job in jobs {
let payload: AnalyzeJobPayload = serde_json::from_value(job.payload)?;
assert!(payload.force);
payload_docs.push((payload.document_id, payload.document_version_id));
}
assert!(payload_docs
.iter()
.all(|(doc_id, _)| *doc_id != second_detail.document.id));
let mut expected = vec![
(
first_detail.document.id,
first_detail
.document
.current_version
.as_ref()
.expect("first current version")
.id,
),
(
third_detail.document.id,
third_detail
.document
.current_version
.as_ref()
.expect("third current version")
.id,
),
];
payload_docs.sort();
expected.sort();
assert_eq!(payload_docs, expected);
app.cleanup().await?;
Ok(())
}
+318
View File
@@ -0,0 +1,318 @@
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,
name: String,
}
#[derive(Deserialize)]
struct FolderContents {
folder: Option<FolderInfo>,
subfolders: Vec<FolderInfo>,
documents: Vec<DocSummary>,
}
#[derive(Deserialize)]
struct DocSummary {
id: Uuid,
}
#[derive(Serialize)]
struct CreateFolder<'a> {
name: &'a str,
parent_id: Option<Uuid>,
}
#[derive(Serialize)]
struct EnsureFolderPath<'a> {
parent_id: Option<Uuid>,
segments: &'a [&'a str],
}
#[derive(Serialize)]
struct UpdateFolderRequest {
#[serde(skip_serializing_if = "Option::is_none")]
parent_id: Option<Option<Uuid>>,
#[serde(skip_serializing_if = "Option::is_none")]
name: Option<String>,
}
#[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 app = TestApp::new().await?;
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(())
}
#[tokio::test]
async fn ensure_path_creates_nested_folders() -> Result<()> {
let _lock = acquire_db_lock().await;
let app = TestApp::new().await?;
let password = "pathpass";
app.insert_user("path-admin", password, "admin").await?;
let token = app.login_token("path-admin", password).await?;
let base_path = EnsureFolderPath {
parent_id: None,
segments: &["Team", "Engineering", "Backend"],
};
let first_resp = app
.post_json("/api/folders/path", &base_path, Some(&token))
.await?;
assert_eq!(first_resp.status(), StatusCode::OK);
let first_body = body_to_vec(first_resp.into_body()).await?;
let first_folder: FolderResponse = serde_json::from_slice(&first_body)?;
let second_resp = app
.post_json("/api/folders/path", &base_path, Some(&token))
.await?;
assert_eq!(second_resp.status(), StatusCode::OK);
let second_body = body_to_vec(second_resp.into_body()).await?;
let second_folder: FolderResponse = serde_json::from_slice(&second_body)?;
assert_eq!(second_folder.folder.id, first_folder.folder.id);
let engineering_resp = app
.post_json(
"/api/folders/path",
&EnsureFolderPath {
parent_id: None,
segments: &["Team", "Engineering"],
},
Some(&token),
)
.await?;
assert_eq!(engineering_resp.status(), StatusCode::OK);
let engineering_body = body_to_vec(engineering_resp.into_body()).await?;
let engineering_folder: FolderResponse = serde_json::from_slice(&engineering_body)?;
assert_ne!(engineering_folder.folder.id, first_folder.folder.id);
let infra_resp = app
.post_json(
"/api/folders/path",
&EnsureFolderPath {
parent_id: Some(engineering_folder.folder.id),
segments: &["Infrastructure"],
},
Some(&token),
)
.await?;
assert_eq!(infra_resp.status(), StatusCode::OK);
let infra_body = body_to_vec(infra_resp.into_body()).await?;
let infra_folder: FolderResponse = serde_json::from_slice(&infra_body)?;
assert_ne!(infra_folder.folder.id, engineering_folder.folder.id);
let infra_dupe_resp = app
.post_json(
"/api/folders/path",
&EnsureFolderPath {
parent_id: Some(engineering_folder.folder.id),
segments: &["Infrastructure"],
},
Some(&token),
)
.await?;
assert_eq!(infra_dupe_resp.status(), StatusCode::OK);
let infra_dupe_body = body_to_vec(infra_dupe_resp.into_body()).await?;
let infra_dupe_folder: FolderResponse = serde_json::from_slice(&infra_dupe_body)?;
assert_eq!(infra_dupe_folder.folder.id, infra_folder.folder.id);
app.cleanup().await?;
Ok(())
}
#[tokio::test]
async fn folder_rename_updates_name_and_child_paths() -> Result<()> {
let _lock = acquire_db_lock().await;
let app = TestApp::new().await?;
let password = "renamepass";
app.insert_user("rename-admin", password, "admin").await?;
let token = app.login_token("rename-admin", password).await?;
let parent_resp = app
.post_json(
"/api/folders",
&CreateFolder {
name: "Projects",
parent_id: None,
},
Some(&token),
)
.await?;
assert_eq!(parent_resp.status(), StatusCode::OK);
let parent_body = body_to_vec(parent_resp.into_body()).await?;
let parent: FolderResponse = serde_json::from_slice(&parent_body)?;
let child_resp = app
.post_json(
"/api/folders",
&CreateFolder {
name: "Q1",
parent_id: Some(parent.folder.id),
},
Some(&token),
)
.await?;
assert_eq!(child_resp.status(), StatusCode::OK);
let child_body = body_to_vec(child_resp.into_body()).await?;
let child: FolderResponse = serde_json::from_slice(&child_body)?;
let rename_resp = app
.patch_json(
&format!("/api/folders/{}", parent.folder.id),
&UpdateFolderRequest {
parent_id: None,
name: Some("Archive".to_string()),
},
Some(&token),
)
.await?;
assert_eq!(rename_resp.status(), StatusCode::NO_CONTENT);
let root_contents = app.get("/api/folders/root/contents", Some(&token)).await?;
assert_eq!(root_contents.status(), StatusCode::OK);
let root_body = body_to_vec(root_contents.into_body()).await?;
let root: FolderContents = serde_json::from_slice(&root_body)?;
let renamed = root
.subfolders
.iter()
.find(|f| f.id == parent.folder.id)
.expect("renamed folder present");
assert_eq!(renamed.name, "Archive");
let folders_only = app
.get(
&format!(
"/api/folders/{}/contents?include_documents=false",
parent.folder.id
),
Some(&token),
)
.await?;
assert_eq!(folders_only.status(), StatusCode::OK);
let folders_only_body = body_to_vec(folders_only.into_body()).await?;
let folders_only_contents: FolderContents = serde_json::from_slice(&folders_only_body)?;
assert!(folders_only_contents.documents.is_empty());
let child_contents = app
.get(
&format!("/api/folders/{}/contents", child.folder.id),
Some(&token),
)
.await?;
assert_eq!(child_contents.status(), StatusCode::OK);
let child_contents_body = body_to_vec(child_contents.into_body()).await?;
let child_details: FolderContents = serde_json::from_slice(&child_contents_body)?;
let child_folder = child_details.folder.expect("child folder info");
assert_eq!(child_folder.name, "Q1");
app.cleanup().await?;
Ok(())
}
+174
View File
@@ -0,0 +1,174 @@
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,
#[allow(dead_code)]
color: Option<String>,
}
#[derive(Deserialize)]
struct TagResponse {
id: Uuid,
label: String,
color: Option<String>,
usage_count: i64,
}
#[derive(Serialize)]
struct AssignTagsRequest {
tag_ids: Vec<Uuid>,
}
#[tokio::test]
async fn tag_assignment_flow() -> Result<()> {
let _lock = acquire_db_lock().await;
let app = TestApp::new().await?;
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?;
assert_eq!(upload.status(), StatusCode::CREATED);
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)?;
assert_eq!(tag.label, "Important");
assert_eq!(tag.color.as_deref(), Some("#FF0000"));
assert_eq!(tag.usage_count, 0);
let update = app
.patch_json(
&format!("/api/tags/{}", tag.id),
&serde_json::json!({
"label": "Critical",
"color": "#00FF00"
}),
Some(&token),
)
.await?;
let updated_status = update.status();
let updated_body = body_to_vec(update.into_body()).await?;
if updated_status != StatusCode::OK {
panic!(
"update tag failed: {}",
String::from_utf8_lossy(&updated_body)
);
}
let updated: TagResponse = serde_json::from_slice(&updated_body)?;
assert_eq!(updated.label, "Critical");
assert_eq!(updated.color.as_deref(), Some("#00FF00"));
assert_eq!(updated.usage_count, 0);
let clear_color = app
.patch_json(
&format!("/api/tags/{}", tag.id),
&serde_json::json!({
"color": null
}),
Some(&token),
)
.await?;
let cleared_status = clear_color.status();
let cleared_body = body_to_vec(clear_color.into_body()).await?;
if cleared_status != StatusCode::OK {
panic!(
"clear color failed: {}",
String::from_utf8_lossy(&cleared_body)
);
}
let cleared: TagResponse = serde_json::from_slice(&cleared_body)?;
assert_eq!(cleared.color, None);
assert_eq!(cleared.usage_count, 0);
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, "Critical");
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(())
}