Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- **Accounts queries** (PRD §6.4, PRD-v2 §P1.3, task 22): three CQRS query handlers (`list_accounts`, `get_account`, `get_account_traffic`) wired through the `QueryBus` builder via a new `with_account_repo` setter. New read models `AccountViewDto` and `AccountTrafficDto` (`#[serde(rename_all = "camelCase")]`) expose every persisted field — `id`, `service_name`, `username`, `account_type`, `enabled`, `traffic_left`, `traffic_total`, `valid_until`, `last_validated`, `created_at`, `credential_ref` — and never carry a password or raw credential field, by construction. `AccountFilter { service_name?, account_type?, enabled? }` AND-combines filters: `service_name` is delegated to the repo's `list_by_service` for SQL-level pruning, while `account_type` and `enabled` filter in memory. `get_account_traffic` returns the persisted counters; the upstream-refresh path is the existing `account_validate` command (task 21), keeping queries side-effect free per the project CQRS rule. 21 new unit tests against an `InMemoryAccountRepoForQueries` fixture cover filter combinations, missing-id 404s, missing-repo validation errors, camelCase serialization shape, and explicit "no password field" assertions on `serde_json::to_value` output. Unblocks task 23 (Vue Accounts).
- **Accounts commands** (PRD §6.4, PRD-v2 §P1.2, task 21): six application-layer command handlers (`add_account`, `update_account`, `delete_account`, `validate_account`, `export_accounts`, `import_accounts`) wired through the `CommandBus` builder. New driven ports `AccountCredentialStore`, `AccountValidator` (with `ValidationOutcome`) and `PassphraseCodec` keep handlers free of plugin / crypto dependencies. `KeyringAccountStore` adapter persists per-account passwords under `vortex-account-{id}` keyring entries; `AesGcmPbkdf2Codec` adapter implements the import / export bundle format using AES-256-GCM with a PBKDF2-HMAC-SHA256 200 000-iteration KDF, fresh per-call salt + nonce, header bound as AAD, and a `VORTACC` magic + version byte so tampered or downgraded bundles fail authentication. Domain events `AccountAdded`, `AccountUpdated`, `AccountDeleted`, `AccountValidated`, `AccountValidationFailed`, `AccountsImported`, `AccountsExported` published via `EventBus` and forwarded by the Tauri bridge as `account-*` browser events. Add rolls back the SQLite row when the keyring write fails so credentials never end up orphaned; import validates every entry up-front and skips `(service_name, username)` pairs already present without inserting partial state. Unblocks task 23 (Vue Accounts).
- **Accounts persistence** (PRD §6.4, PRD-v2 §P1.1, task 20): SQLite `accounts` table (migration `m20260428_000006`) with `id` / `service_name` / `username` / `account_type` / `enabled` / `traffic_left` / `traffic_total` / `valid_until` / `last_validated` / `created_at` columns and a UNIQUE `(service_name, username)` index. New `AccountRepository` driven port (`save` / `find_by_id` / `list` / `list_by_service` / `delete`) and `SqliteAccountRepo` adapter with sea-orm entity + `from_domain` / `into_domain` converters. UNIQUE violations surface as `DomainError::AlreadyExists` instead of leaking storage errors. Domain `Account` aggregate gained `traffic_total`, `last_validated`, `created_at` fields and switched its identifier to `AccountId(String)` so generated account ids match the spec's `TEXT PRIMARY KEY`. `Account::credential_ref()` returns a `keyring://{service}/{username}` URI exposing a logical reference suitable for diagnostics; passwords themselves are never persisted to SQLite — they live in the OS keychain via the `AccountCredentialStore` adapter (added in task 21, keyed by `AccountId`). Unblocks tasks 21-25, 38, 51-56, 75-76.

Expand Down
109 changes: 109 additions & 0 deletions src-tauri/src/application/queries/get_account.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
//! Handler for [`GetAccountQuery`].
//!
//! Returns a single account as an [`AccountViewDto`]. Returns
//! `AppError::NotFound` when the id does not match any persisted row.

use crate::application::error::AppError;
use crate::application::query_bus::QueryBus;
use crate::application::read_models::account_view::AccountViewDto;

impl QueryBus {
pub async fn handle_get_account(
&self,
query: super::GetAccountQuery,
) -> Result<AccountViewDto, AppError> {
let repo = self
.account_repo()
.ok_or_else(|| AppError::Validation("account repository not configured".into()))?;

let account = repo
.find_by_id(&query.id)?
.ok_or_else(|| AppError::NotFound(format!("account {}", query.id.as_str())))?;

Ok(account.into())
}
}

#[cfg(test)]
mod tests {
use std::sync::Arc;

use crate::application::error::AppError;
use crate::application::queries::GetAccountQuery;
use crate::application::test_support::{
InMemoryAccountRepoForQueries, query_bus_with_accounts,
};
use crate::domain::model::account::{Account, AccountId, AccountType};
use crate::domain::ports::driven::AccountRepository;

fn populate_repo() -> Arc<InMemoryAccountRepoForQueries> {
let repo = Arc::new(InMemoryAccountRepoForQueries::new());
repo.save(&Account::new(
AccountId::new("acc-1"),
"real-debrid".to_string(),
"alice".to_string(),
AccountType::Premium,
1_700_000_000_000,
))
.unwrap();
repo
}

#[tokio::test]
async fn test_get_account_returns_dto_when_found() {
let repo = populate_repo();
let bus = query_bus_with_accounts(repo);
let dto = bus
.handle_get_account(GetAccountQuery {
id: AccountId::new("acc-1"),
})
.await
.unwrap();
assert_eq!(dto.id, "acc-1");
assert_eq!(dto.service_name, "real-debrid");
assert_eq!(dto.username, "alice");
assert_eq!(dto.account_type, "premium");
}

#[tokio::test]
async fn test_get_account_returns_not_found_when_missing() {
let repo = populate_repo();
let bus = query_bus_with_accounts(repo);
let err = bus
.handle_get_account(GetAccountQuery {
id: AccountId::new("ghost"),
})
.await
.expect_err("ghost id");
assert!(matches!(err, AppError::NotFound(msg) if msg.contains("ghost")));
}

#[tokio::test]
async fn test_get_account_dto_omits_password_field() {
let repo = populate_repo();
let bus = query_bus_with_accounts(repo);
let dto = bus
.handle_get_account(GetAccountQuery {
id: AccountId::new("acc-1"),
})
.await
.unwrap();
let value = serde_json::to_value(&dto).unwrap();
let object = value.as_object().unwrap();
assert!(!object.contains_key("password"));
}

#[tokio::test]
async fn test_get_account_returns_validation_error_when_repo_missing() {
let bus = crate::application::test_support::make_history_query_bus(Arc::new(
crate::application::test_support::NoopHistoryRepo,
));
let err = bus
.handle_get_account(GetAccountQuery {
id: AccountId::new("acc-1"),
})
.await
.expect_err("missing repo");
assert!(matches!(err, AppError::Validation(_)));
}
}
124 changes: 124 additions & 0 deletions src-tauri/src/application/queries/get_account_traffic.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
//! Handler for [`GetAccountTrafficQuery`].
//!
//! Reads the persisted traffic counters for one account. The "refresh
//! from upstream" step is deliberately not in this handler — that
//! mutates state and lives in the [`ValidateAccountCommand`](
//! crate::application::commands::ValidateAccountCommand) handler.
//! Splitting them this way keeps queries side-effect free, in line with
//! the project-wide CQRS rule.

use crate::application::error::AppError;
use crate::application::query_bus::QueryBus;
use crate::application::read_models::account_view::AccountTrafficDto;

impl QueryBus {
pub async fn handle_get_account_traffic(
&self,
query: super::GetAccountTrafficQuery,
) -> Result<AccountTrafficDto, AppError> {
let repo = self
.account_repo()
.ok_or_else(|| AppError::Validation("account repository not configured".into()))?;

let account = repo
.find_by_id(&query.id)?
.ok_or_else(|| AppError::NotFound(format!("account {}", query.id.as_str())))?;

Ok(account.into())
}
}

#[cfg(test)]
mod tests {
use std::sync::Arc;

use crate::application::error::AppError;
use crate::application::queries::GetAccountTrafficQuery;
use crate::application::test_support::{
InMemoryAccountRepoForQueries, query_bus_with_accounts,
};
use crate::domain::model::account::{Account, AccountId, AccountType};
use crate::domain::ports::driven::AccountRepository;

#[tokio::test]
async fn test_get_account_traffic_returns_persisted_counters() {
let repo = Arc::new(InMemoryAccountRepoForQueries::new());
let mut acc = Account::new(
AccountId::new("acc-1"),
"real-debrid".to_string(),
"alice".to_string(),
AccountType::Premium,
1_700_000_000_000,
);
acc.set_traffic_left(50_000);
acc.set_traffic_total(100_000);
acc.set_valid_until(2_500_000_000_000);
acc.set_last_validated(1_900_000_000_000);
repo.save(&acc).unwrap();

let bus = query_bus_with_accounts(repo);
let dto = bus
.handle_get_account_traffic(GetAccountTrafficQuery {
id: AccountId::new("acc-1"),
})
.await
.unwrap();
assert_eq!(dto.id, "acc-1");
assert_eq!(dto.traffic_left, Some(50_000));
assert_eq!(dto.traffic_total, Some(100_000));
assert_eq!(dto.valid_until, Some(2_500_000_000_000));
assert_eq!(dto.last_validated, Some(1_900_000_000_000));
}

#[tokio::test]
async fn test_get_account_traffic_returns_none_counters_when_unset() {
let repo = Arc::new(InMemoryAccountRepoForQueries::new());
repo.save(&Account::new(
AccountId::new("acc-2"),
"service".to_string(),
"u".to_string(),
AccountType::Free,
0,
))
.unwrap();

let bus = query_bus_with_accounts(repo);
let dto = bus
.handle_get_account_traffic(GetAccountTrafficQuery {
id: AccountId::new("acc-2"),
})
.await
.unwrap();
assert_eq!(dto.traffic_left, None);
assert_eq!(dto.traffic_total, None);
assert_eq!(dto.valid_until, None);
assert_eq!(dto.last_validated, None);
}

#[tokio::test]
async fn test_get_account_traffic_returns_not_found_when_missing() {
let repo = Arc::new(InMemoryAccountRepoForQueries::new());
let bus = query_bus_with_accounts(repo);
let err = bus
.handle_get_account_traffic(GetAccountTrafficQuery {
id: AccountId::new("ghost"),
})
.await
.expect_err("ghost id");
assert!(matches!(err, AppError::NotFound(msg) if msg.contains("ghost")));
}

#[tokio::test]
async fn test_get_account_traffic_returns_validation_error_when_repo_missing() {
let bus = crate::application::test_support::make_history_query_bus(Arc::new(
crate::application::test_support::NoopHistoryRepo,
));
let err = bus
.handle_get_account_traffic(GetAccountTrafficQuery {
id: AccountId::new("acc-1"),
})
.await
.expect_err("missing repo");
assert!(matches!(err, AppError::Validation(_)));
}
}
Loading
Loading