structural refactor and codebase improvements

This commit is contained in:
2026-08-09 14:58:14 +02:00
parent 22b1dd3f56
commit c9715baab8
247 changed files with 11515 additions and 3063 deletions

View File

@@ -0,0 +1,64 @@
//! Absorbs `handlers/import.rs::api_apply_profile`'s three-step orchestration:
//! apply the saved profile's field mappings onto the session, reload the
//! session to read back the mappings `apply_profile` just wrote, then run
//! `apply_mapping` to regenerate `row_results` from them. All three steps used
//! to live in the handler; this use case is the only caller-visible change —
//! the two existing use cases it drives (`apply_profile::execute`,
//! `apply_mapping::execute`) are untouched, per this task's constraint against
//! reshaping already-existing use-case signatures.
use domain::{errors::DomainError, value_objects::ImportSessionId};
use crate::import::{
apply_mapping, apply_profile,
commands::{ApplyImportMappingCommand, ApplyImportProfileCommand, ApplyProfileAndMapCommand},
deps::{ApplyMappingDeps, ApplyProfileAndMapDeps, ApplyProfileDeps},
};
pub async fn execute(
deps: &ApplyProfileAndMapDeps,
cmd: ApplyProfileAndMapCommand,
) -> Result<Vec<domain::models::AnnotatedRow>, DomainError> {
let profile_deps = ApplyProfileDeps {
import_profile: deps.import_profile.clone(),
import_session: deps.import_session.clone(),
};
apply_profile::execute(
&profile_deps,
ApplyImportProfileCommand {
user_id: cmd.user_id,
session_id: cmd.session_id,
profile_id: cmd.profile_id,
},
)
.await?;
let session_id = ImportSessionId::from_uuid(cmd.session_id);
let user_id = domain::value_objects::UserId::from_uuid(cmd.user_id);
let session = deps
.import_session
.get(&session_id, &user_id)
.await?
.ok_or_else(|| DomainError::NotFound("session not found after profile apply".into()))?;
let mappings = session.field_mappings.unwrap_or_default();
let mapping_deps = ApplyMappingDeps {
import_session: deps.import_session.clone(),
document_parser: deps.document_parser.clone(),
movie_query: deps.movie_query.clone(),
};
apply_mapping::execute(
&mapping_deps,
ApplyImportMappingCommand {
user_id: cmd.user_id,
session_id: cmd.session_id,
mappings,
},
)
.await
}
#[cfg(test)]
#[path = "tests/apply_profile_and_map.rs"]
mod tests;

View File

@@ -31,6 +31,12 @@ pub struct ApplyImportProfileCommand {
pub profile_id: Uuid,
}
pub struct ApplyProfileAndMapCommand {
pub user_id: Uuid,
pub session_id: Uuid,
pub profile_id: Uuid,
}
pub struct DeleteImportProfileCommand {
pub user_id: Uuid,
pub profile_id: Uuid,

View File

@@ -1,24 +1,22 @@
use std::sync::Arc;
use crate::import::commands::DeleteImportProfileCommand;
use crate::import::deps::DeleteImportProfileDeps;
use domain::{
errors::DomainError,
ports::ImportProfileRepository,
value_objects::{ImportProfileId, UserId},
};
pub async fn execute(
import_profile: Arc<dyn ImportProfileRepository>,
deps: &DeleteImportProfileDeps,
cmd: DeleteImportProfileCommand,
) -> Result<(), DomainError> {
let user_id = UserId::from_uuid(cmd.user_id);
let profile_id = ImportProfileId::from_uuid(cmd.profile_id);
import_profile
deps.import_profile
.get(&profile_id, &user_id)
.await?
.ok_or_else(|| DomainError::NotFound("import profile".into()))?;
import_profile.delete(&profile_id).await
deps.import_profile.delete(&profile_id).await
}
#[cfg(test)]

View File

@@ -29,3 +29,35 @@ pub struct SaveProfileDeps {
pub import_session: Arc<dyn ImportSessionRepository>,
pub import_profile: Arc<dyn ImportProfileRepository>,
}
pub struct GetMappingStageDeps {
pub import_session: Arc<dyn ImportSessionRepository>,
}
pub struct GetPreviewStageDeps {
pub import_session: Arc<dyn ImportSessionRepository>,
}
pub struct GetSessionStateDeps {
pub import_session: Arc<dyn ImportSessionRepository>,
}
pub struct DeleteImportProfileDeps {
pub import_profile: Arc<dyn ImportProfileRepository>,
}
pub struct ListImportProfilesDeps {
pub import_profile: Arc<dyn ImportProfileRepository>,
}
/// Backs `apply_profile_and_map`, which internally drives `apply_profile::execute`
/// then `apply_mapping::execute` — these fields are exactly the union of
/// `ApplyProfileDeps` and `ApplyMappingDeps`'s fields, cloned once here and used to
/// build each nested deps struct inline at the call site (see that file's doc
/// comment for why: no use-case signature changes, per this task's constraints).
pub struct ApplyProfileAndMapDeps {
pub import_profile: Arc<dyn ImportProfileRepository>,
pub import_session: Arc<dyn ImportSessionRepository>,
pub document_parser: Arc<dyn DocumentParser>,
pub movie_query: Arc<dyn MovieQuery>,
}

View File

@@ -0,0 +1,47 @@
//! The mapping-page stage gate: a session must exist and have a `parsed_file`
//! before its columns/sample rows can be shown for field mapping. Absorbs
//! `handlers/import.rs::get_mapping_page`'s two early-return checks (session
//! missing, `parsed_file` absent) — both collapse to `NotFound` here since the
//! handler redirected to the same place (`/import`) for either.
use domain::{errors::DomainError, value_objects::ImportSessionId};
use uuid::Uuid;
use crate::import::deps::GetMappingStageDeps;
/// Cap on sample rows shown on the mapping page — was a bare `.take(5)` in the
/// handler.
pub const SAMPLE_ROW_LIMIT: usize = 5;
pub struct MappingStage {
pub columns: Vec<String>,
pub sample_rows: Vec<Vec<String>>,
}
pub async fn execute(
deps: &GetMappingStageDeps,
session_id: ImportSessionId,
user_id: Uuid,
) -> Result<MappingStage, DomainError> {
let user_id = domain::value_objects::UserId::from_uuid(user_id);
let session = deps
.import_session
.get(&session_id, &user_id)
.await?
.ok_or_else(|| DomainError::NotFound("import session".into()))?;
let parsed = session
.parsed_file
.ok_or_else(|| DomainError::NotFound("import session has no parsed file".into()))?;
let sample_rows = parsed.rows.into_iter().take(SAMPLE_ROW_LIMIT).collect();
Ok(MappingStage {
columns: parsed.columns,
sample_rows,
})
}
#[cfg(test)]
#[path = "tests/get_mapping_stage.rs"]
mod tests;

View File

@@ -0,0 +1,59 @@
//! The preview-page stage gate: a session must have `row_results` (i.e. a
//! mapping has already been applied) before its rows can be previewed. Serves
//! both the HTML preview handler and the API preview handler —
//! `handlers/import.rs::get_preview_page` and `::api_get_preview` — which
//! render/respond to `NotYetMapped` differently (redirect vs. status code); that
//! decision stays in the handlers, not here.
use domain::{
errors::DomainError,
models::AnnotatedRow,
value_objects::{ImportSessionId, UserId},
};
use uuid::Uuid;
use crate::import::deps::GetPreviewStageDeps;
/// The columns and mapped/annotated rows for a session whose mapping has
/// already been applied. `columns` comes from the session's `parsed_file` —
/// the HTML preview template renders it as the table header — while `rows`
/// comes from `row_results`. Not in the brief's `PreviewStage::Ready(Vec<AnnotatedRow>)`
/// sketch: the deleted `get_preview_page` handler code read both
/// `session.parsed_file.columns` and `session.row_results` to render the page,
/// so dropping `columns` here would either blank the preview table's header or
/// force the handler to re-fetch the session itself (forbidden — that's the
/// exact repo call this task removes). See task-2 report for detail.
pub struct PreviewRows {
pub columns: Vec<String>,
pub rows: Vec<AnnotatedRow>,
}
pub enum PreviewStage {
Ready(PreviewRows),
NotYetMapped,
}
pub async fn execute(
deps: &GetPreviewStageDeps,
session_id: ImportSessionId,
user_id: Uuid,
) -> Result<PreviewStage, DomainError> {
let user_id = UserId::from_uuid(user_id);
let session = deps
.import_session
.get(&session_id, &user_id)
.await?
.ok_or_else(|| DomainError::NotFound("session not found".into()))?;
let Some(rows) = session.row_results else {
return Ok(PreviewStage::NotYetMapped);
};
let columns = session.parsed_file.map(|p| p.columns).unwrap_or_default();
Ok(PreviewStage::Ready(PreviewRows { columns, rows }))
}
#[cfg(test)]
#[path = "tests/get_preview_stage.rs"]
mod tests;

View File

@@ -0,0 +1,43 @@
//! Backs `handlers/import.rs::api_get_session` — a plain state query, not a
//! redirect-driving gate (the API has nothing to redirect to; a missing
//! session is just a 404).
use domain::{
errors::DomainError,
value_objects::{ImportSessionId, UserId},
};
use uuid::Uuid;
use crate::import::deps::GetSessionStateDeps;
pub struct SessionState {
pub columns: Vec<String>,
pub has_mappings: bool,
pub row_count: usize,
}
pub async fn execute(
deps: &GetSessionStateDeps,
session_id: ImportSessionId,
user_id: Uuid,
) -> Result<SessionState, DomainError> {
let user_id = UserId::from_uuid(user_id);
let session = deps
.import_session
.get(&session_id, &user_id)
.await?
.ok_or_else(|| DomainError::NotFound("session not found".into()))?;
let parsed = session.parsed_file.unwrap_or_default();
let row_count = parsed.rows.len();
Ok(SessionState {
columns: parsed.columns,
has_mappings: session.field_mappings.is_some(),
row_count,
})
}
#[cfg(test)]
#[path = "tests/get_session_state.rs"]
mod tests;

View File

@@ -1,15 +1,11 @@
use std::sync::Arc;
use domain::{
errors::DomainError, models::ImportProfile, ports::ImportProfileRepository,
value_objects::UserId,
};
use crate::import::deps::ListImportProfilesDeps;
use domain::{errors::DomainError, models::ImportProfile, value_objects::UserId};
pub async fn execute(
import_profile: Arc<dyn ImportProfileRepository>,
deps: &ListImportProfilesDeps,
user_id: &UserId,
) -> Result<Vec<ImportProfile>, DomainError> {
import_profile.list_for_user(user_id).await
deps.import_profile.list_for_user(user_id).await
}
#[cfg(test)]

View File

@@ -1,10 +1,14 @@
pub mod apply_mapping;
pub mod apply_profile;
pub mod apply_profile_and_map;
pub mod cleanup;
pub mod commands;
pub mod create_session;
pub mod delete_profile;
pub mod deps;
pub mod execute;
pub mod get_mapping_stage;
pub mod get_preview_stage;
pub mod get_session_state;
pub mod list_profiles;
pub mod save_profile;

View File

@@ -0,0 +1,108 @@
use std::sync::Arc;
use chrono::Utc;
use uuid::Uuid;
use domain::models::import::{DomainField, Transform};
use domain::models::{FieldMapping, FileFormat, ImportProfile};
use domain::ports::{ImportProfileRepository, ImportSessionRepository};
use domain::testing::{InMemoryImportProfileRepository, InMemoryImportSessionRepository};
use domain::value_objects::{ImportProfileId, UserId};
use crate::import::deps::{ApplyProfileAndMapDeps, CreateSessionDeps};
use crate::import::{
apply_profile_and_map, commands::ApplyProfileAndMapCommand,
commands::CreateImportSessionCommand, create_session,
};
use crate::test_helpers::TestContextBuilder;
#[tokio::test]
async fn fails_when_profile_not_found() {
let profiles = InMemoryImportProfileRepository::new();
let sessions = InMemoryImportSessionRepository::new();
let b = TestContextBuilder::new();
let deps = ApplyProfileAndMapDeps {
import_profile: Arc::clone(&profiles) as _,
import_session: Arc::clone(&sessions) as _,
document_parser: b.document_parser.clone(),
movie_query: b.movie_query.clone(),
};
let result = apply_profile_and_map::execute(
&deps,
ApplyProfileAndMapCommand {
user_id: Uuid::new_v4(),
session_id: Uuid::new_v4(),
profile_id: Uuid::new_v4(),
},
)
.await;
assert!(result.is_err());
}
#[tokio::test]
async fn applies_profile_then_regenerates_mapping() {
let profiles = InMemoryImportProfileRepository::new();
let sessions = InMemoryImportSessionRepository::new();
let b = TestContextBuilder::new();
let user_id = Uuid::new_v4();
let profile = ImportProfile::new(
ImportProfileId::generate(),
UserId::from_uuid(user_id),
"letterboxd".into(),
vec![FieldMapping {
source_column: "title".into(),
domain_field: DomainField::Title,
transform: Transform::Identity,
}],
Utc::now().naive_utc(),
);
let profile_id = profile.id.clone();
profiles.save(&profile).await.unwrap();
let create_deps = CreateSessionDeps {
import_session: Arc::clone(&sessions) as _,
document_parser: b.document_parser.clone(),
};
let created = create_session::execute(
&create_deps,
CreateImportSessionCommand {
user_id,
bytes: b"title\nTest".to_vec(),
format: FileFormat::Csv,
},
)
.await
.unwrap();
let deps = ApplyProfileAndMapDeps {
import_profile: Arc::clone(&profiles) as _,
import_session: Arc::clone(&sessions) as _,
document_parser: b.document_parser.clone(),
movie_query: b.movie_query.clone(),
};
let rows = apply_profile_and_map::execute(
&deps,
ApplyProfileAndMapCommand {
user_id,
session_id: created.session_id.value(),
profile_id: profile_id.value(),
},
)
.await
.unwrap();
assert!(!rows.is_empty());
let updated = sessions
.get(&created.session_id, &UserId::from_uuid(user_id))
.await
.unwrap()
.unwrap();
assert!(updated.row_results.is_some());
assert!(updated.field_mappings.is_some());
}

View File

@@ -3,14 +3,19 @@ use std::sync::Arc;
use domain::testing::InMemoryImportProfileRepository;
use uuid::Uuid;
use crate::import::{commands::DeleteImportProfileCommand, delete_profile};
use crate::import::{
commands::DeleteImportProfileCommand, delete_profile, deps::DeleteImportProfileDeps,
};
#[tokio::test]
async fn fails_when_profile_not_found() {
let profiles = InMemoryImportProfileRepository::new();
let deps = DeleteImportProfileDeps {
import_profile: Arc::clone(&profiles) as _,
};
let result = delete_profile::execute(
Arc::clone(&profiles) as _,
&deps,
DeleteImportProfileCommand {
user_id: Uuid::new_v4(),
profile_id: Uuid::new_v4(),

View File

@@ -0,0 +1,68 @@
use std::sync::Arc;
use uuid::Uuid;
use domain::models::ImportSession;
use domain::models::import::ParsedFile;
use domain::ports::ImportSessionRepository;
use domain::testing::InMemoryImportSessionRepository;
use domain::value_objects::{ImportSessionId, UserId};
use crate::import::deps::GetMappingStageDeps;
use crate::import::get_mapping_stage::{self, SAMPLE_ROW_LIMIT};
#[tokio::test]
async fn get_mapping_stage_is_not_found_when_file_not_parsed() {
let sessions = InMemoryImportSessionRepository::new();
let user_id = Uuid::new_v4();
let session = ImportSession::new(UserId::from_uuid(user_id));
let session_id = session.id.clone();
sessions.create(&session).await.unwrap();
let deps = GetMappingStageDeps {
import_session: Arc::clone(&sessions) as _,
};
let result = get_mapping_stage::execute(&deps, session_id, user_id).await;
assert!(result.is_err());
}
#[tokio::test]
async fn get_mapping_stage_is_not_found_when_session_missing() {
let sessions = InMemoryImportSessionRepository::new();
let deps = GetMappingStageDeps {
import_session: Arc::clone(&sessions) as _,
};
let result =
get_mapping_stage::execute(&deps, ImportSessionId::generate(), Uuid::new_v4()).await;
assert!(result.is_err());
}
#[tokio::test]
async fn get_mapping_stage_returns_columns_and_capped_sample_rows() {
let sessions = InMemoryImportSessionRepository::new();
let user_id = Uuid::new_v4();
let mut session = ImportSession::new(UserId::from_uuid(user_id));
session.parsed_file = Some(ParsedFile {
columns: vec!["Name".into(), "Year".into()],
rows: (0..7)
.map(|i| vec![format!("row{i}"), "2020".into()])
.collect(),
});
let session_id = session.id.clone();
sessions.create(&session).await.unwrap();
let deps = GetMappingStageDeps {
import_session: Arc::clone(&sessions) as _,
};
let stage = get_mapping_stage::execute(&deps, session_id, user_id)
.await
.unwrap();
assert_eq!(stage.columns, vec!["Name".to_string(), "Year".to_string()]);
assert_eq!(stage.sample_rows.len(), SAMPLE_ROW_LIMIT);
}

View File

@@ -0,0 +1,80 @@
use std::sync::Arc;
use uuid::Uuid;
use domain::models::import::{ImportRow, ParsedFile, RowResult};
use domain::models::{AnnotatedRow, ImportSession};
use domain::ports::ImportSessionRepository;
use domain::testing::InMemoryImportSessionRepository;
use domain::value_objects::{ImportSessionId, UserId};
use crate::import::deps::GetPreviewStageDeps;
use crate::import::get_preview_stage::{self, PreviewStage};
#[tokio::test]
async fn get_preview_stage_reports_not_yet_mapped_when_row_results_absent() {
let sessions = InMemoryImportSessionRepository::new();
let user_id = Uuid::new_v4();
let session = ImportSession::new(UserId::from_uuid(user_id));
let session_id = session.id.clone();
sessions.create(&session).await.unwrap();
let deps = GetPreviewStageDeps {
import_session: Arc::clone(&sessions) as _,
};
let stage = get_preview_stage::execute(&deps, session_id, user_id)
.await
.unwrap();
assert!(matches!(stage, PreviewStage::NotYetMapped));
}
#[tokio::test]
async fn get_preview_stage_returns_rows_once_mapped() {
let sessions = InMemoryImportSessionRepository::new();
let user_id = Uuid::new_v4();
let mut session = ImportSession::new(UserId::from_uuid(user_id));
session.parsed_file = Some(ParsedFile {
columns: vec!["Name".into()],
rows: vec![vec!["Test".into()]],
});
session.row_results = Some(vec![AnnotatedRow {
result: RowResult::Valid(ImportRow {
title: Some("Test".into()),
..ImportRow::default()
}),
is_duplicate: false,
}]);
let session_id = session.id.clone();
sessions.create(&session).await.unwrap();
let deps = GetPreviewStageDeps {
import_session: Arc::clone(&sessions) as _,
};
let stage = get_preview_stage::execute(&deps, session_id, user_id)
.await
.unwrap();
match stage {
PreviewStage::Ready(preview) => {
assert_eq!(preview.columns, vec!["Name".to_string()]);
assert_eq!(preview.rows.len(), 1);
}
PreviewStage::NotYetMapped => panic!("expected Ready, got NotYetMapped"),
}
}
#[tokio::test]
async fn get_preview_stage_is_not_found_when_session_missing() {
let sessions = InMemoryImportSessionRepository::new();
let deps = GetPreviewStageDeps {
import_session: Arc::clone(&sessions) as _,
};
let result =
get_preview_stage::execute(&deps, ImportSessionId::generate(), Uuid::new_v4()).await;
assert!(result.is_err());
}

View File

@@ -0,0 +1,50 @@
use std::sync::Arc;
use uuid::Uuid;
use domain::models::ImportSession;
use domain::models::import::ParsedFile;
use domain::ports::ImportSessionRepository;
use domain::testing::InMemoryImportSessionRepository;
use domain::value_objects::{ImportSessionId, UserId};
use crate::import::deps::GetSessionStateDeps;
use crate::import::get_session_state;
#[tokio::test]
async fn get_session_state_is_not_found_when_session_missing() {
let sessions = InMemoryImportSessionRepository::new();
let deps = GetSessionStateDeps {
import_session: Arc::clone(&sessions) as _,
};
let result =
get_session_state::execute(&deps, ImportSessionId::generate(), Uuid::new_v4()).await;
assert!(result.is_err());
}
#[tokio::test]
async fn get_session_state_reports_columns_row_count_and_mapping_status() {
let sessions = InMemoryImportSessionRepository::new();
let user_id = Uuid::new_v4();
let mut session = ImportSession::new(UserId::from_uuid(user_id));
session.parsed_file = Some(ParsedFile {
columns: vec!["Name".into()],
rows: vec![vec!["a".into()], vec!["b".into()]],
});
let session_id = session.id.clone();
sessions.create(&session).await.unwrap();
let deps = GetSessionStateDeps {
import_session: Arc::clone(&sessions) as _,
};
let state = get_session_state::execute(&deps, session_id, user_id)
.await
.unwrap();
assert_eq!(state.columns, vec!["Name".to_string()]);
assert_eq!(state.row_count, 2);
assert!(!state.has_mappings);
}

View File

@@ -4,16 +4,17 @@ use domain::testing::InMemoryImportProfileRepository;
use domain::value_objects::UserId;
use uuid::Uuid;
use crate::import::list_profiles;
use crate::import::{deps::ListImportProfilesDeps, list_profiles};
#[tokio::test]
async fn returns_empty_when_no_profiles() {
let profiles = InMemoryImportProfileRepository::new();
let deps = ListImportProfilesDeps {
import_profile: Arc::clone(&profiles) as _,
};
let user_id = UserId::from_uuid(Uuid::new_v4());
let result = list_profiles::execute(Arc::clone(&profiles) as _, &user_id)
.await
.unwrap();
let result = list_profiles::execute(&deps, &user_id).await.unwrap();
assert!(result.is_empty());
}