Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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