diff --git a/CHANGELOG.md b/CHANGELOG.md index 4cc17d35..8e91cbd5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **Account auto-selection** (PRD §6.4, PRD-v2 §P1.5, task 24): new `AccountSelector` application service picks the best `Account` per service for the live `AppConfig::account_selection_strategy`. Three strategies: `BestTraffic` (default, ranks `enabled → not expired → most traffic_left → most recent last_validated → smallest id` with `Unlimited` traffic ranking above any finite value), `RoundRobin` (per-service cursor over enabled non-expired candidates ordered by id; a poisoned cursor mutex now surfaces as `AppError::Validation("round-robin cursor mutex poisoned")` so it stays distinguishable from "no eligible account"), and `Manual` (fallback alias of `BestTraffic` until pinning UI lands). The selector reads `AccountRepository::list_by_service` on every call instead of caching: the previous event-driven invalidation could read stale rows when `select_best` landed between `bus.publish(AccountUpdated)` and the spawned `TokioEventBus` subscriber firing. New `CommandBus::resolve_account_for(service_name)` exposes the selector to download / link-grabber flows; failures from `ConfigStore::get_config()` propagate via `?` instead of being swallowed by a default-strategy fallback. New `DomainEvent::NoAccountAvailable { service_name }` (emitted when no candidate passes the filter) and `DomainEvent::AccountSelected { id, service_name, strategy }` (emitted whenever a pick is made), both forwarded by the Tauri bridge as `no-account-available` / `account-selected`. New `account_selection_strategy` field on `AppConfig` / `ConfigPatch` / `apply_patch` plus the matching IPC and TOML serialisation paths (snake_case `"best_traffic" | "round_robin" | "manual"`). The IPC layer rejects unknown strategy values: `ConfigPatchDto` → `ConfigPatch` is `TryFrom` and `settings_update` surfaces `invalid account selection strategy: …` instead of silently ignoring a typo. The TOML store mirrors the rule: `ConfigDto` → `AppConfig` is also `TryFrom`, so a hand-edited `config.toml` carrying an unknown strategy value now fails fast with `StorageError("invalid config: …")` instead of silently coercing to `best_traffic`. Backward compat is preserved: a legacy `config.toml` written before this field existed deserializes the missing key as the empty string via `#[serde(default)]`, and that empty case is treated as `BestTraffic` so an upgrade does not break startup. Eighteen unit tests cover the four acceptance criteria (3-account scenario, all-expired surface, comparative ranking table, round-robin alternance), repo-fresh selection, poisoned-cursor surfacing, IPC rejection of unknown strategies, TOML-store rejection of unknown persisted strategies, legacy-config (missing strategy field) backward compat, and config-error propagation. Unblocks task 25 (rotation auto sur quota). - **Accounts view** (PRD §6.4, PRD-v2 §P1.4, task 23): full Accounts management UI replacing the previous `PlaceholderView`. Header tabs (`All` / `Debrid` / `Premium` / `Free`) drive a category filter on top of the SQLite-backed `account_list` query, with the `(filter, all)` count rendered next to each label. Each row exposes the service, username, account type, derived status badge (`Active` / `Expired` / `Disabled` / `Unverified`), an aria-labelled traffic progress bar (used / total formatted via `formatBytes`), `valid_until` and `last_validated` columns, an enable/disable `Switch`, an inline `Validate` button, and a kebab menu with `Edit` / `Delete`. The new `AddAccountDialog` validates non-empty service / username / password before submission. `EditAccountDialog` posts a partial `AccountPatch` (skips fields that did not change so the keyring rotation only fires when the password field is filled). The `Delete` action honours the existing `settings.confirm_delete` toggle: when enabled it pops the new `DeleteAccountDialog` (translated description naming the row), otherwise it deletes immediately. `ImportAccountsDialog` calls `tauri-plugin-dialog`'s file-pick to anchor the encrypted bundle path, prompts for the passphrase, then calls `account_import` and invalidates the list cache so freshly-imported rows appear without a manual refresh; `ExportAccountsDialog` requires the user to confirm the passphrase, opens the native `save` dialog for the destination, and reports the row count via toast. Nine new Tauri IPC commands wire the existing `CommandBus` / `QueryBus` handlers (tasks 21, 22) to the frontend: `account_add`, `account_update`, `account_delete`, `account_validate`, `account_export`, `account_import`, `account_list`, `account_get`, `account_traffic_get`, all registered in `invoke_handler!` and re-exported from `lib.rs`. The runtime now wires `SqliteAccountRepo` to both buses and provides the `KeyringAccountStore` + `AesGcmPbkdf2Codec` adapters to the `CommandBus`. Adds `useAccountsQuery` (TanStack Query, 30 s `staleTime`) and `accountQueries` cache key factory. New i18n namespace `accounts.*` covers titles, status badges, dialog copy and toast messages in `en.json` + `fr.json`. 13 Vitest tests cover render, empty state, category filter, add → IPC → toast flow, delete → confirm → IPC, export trigger disabled when no accounts, export with passphrase, import with file picker. `AccountValidator` is intentionally not wired in this commit — `account_validate` returns the configured `Validation` error until the first hoster plugin lands (task 38), letting the UI render the failure toast without crashing. The "volume per account" stat from the requirements list is deferred until `history` gains an `account_id` column. - **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). diff --git a/src-tauri/src/adapters/driven/config/toml_config_store.rs b/src-tauri/src/adapters/driven/config/toml_config_store.rs index 4489f05d..7f0225b1 100644 --- a/src-tauri/src/adapters/driven/config/toml_config_store.rs +++ b/src-tauri/src/adapters/driven/config/toml_config_store.rs @@ -7,6 +7,7 @@ use std::path::PathBuf; use std::sync::Mutex; use crate::domain::error::DomainError; +use crate::domain::model::account::AccountSelectionStrategy; use crate::domain::model::config::{ AppConfig, ConfigPatch, apply_patch, normalize_history_retention_days, }; @@ -63,7 +64,9 @@ impl TomlConfigStore { .map_err(|e| DomainError::StorageError(format!("failed to read config: {e}")))?; let dto: ConfigDto = toml::from_str(&content) .map_err(|e| DomainError::StorageError(format!("failed to parse config: {e}")))?; - Ok(AppConfig::from(dto)) + let config = AppConfig::try_from(dto) + .map_err(|e| DomainError::StorageError(format!("invalid config: {e}")))?; + Ok(config) } fn write_config(&self, config: &AppConfig) -> Result<(), DomainError> { @@ -162,6 +165,9 @@ struct ConfigDto { // History history_retention_days: i64, + // Accounts + account_selection_strategy: String, + // Network proxy_type: String, proxy_url: Option, @@ -216,6 +222,7 @@ impl From for ConfigDto { dynamic_split_enabled: c.dynamic_split_enabled, dynamic_split_min_remaining_mb: c.dynamic_split_min_remaining_mb, history_retention_days: c.history_retention_days, + account_selection_strategy: c.account_selection_strategy.to_string(), proxy_type: c.proxy_type, proxy_url: c.proxy_url, user_agent: c.user_agent, @@ -237,9 +244,22 @@ impl From for ConfigDto { } } -impl From for AppConfig { - fn from(d: ConfigDto) -> Self { - Self { +impl TryFrom for AppConfig { + type Error = DomainError; + + fn try_from(d: ConfigDto) -> Result { + // Backward compat: legacy `config.toml` files written before the + // `account_selection_strategy` field existed deserialize via + // `#[serde(default)]` as the empty string. Treat that as + // `DEFAULT` so an upgrade does not fail at startup; reject any + // non-empty unknown value as the typo / corruption it actually is. + let account_selection_strategy: AccountSelectionStrategy = + if d.account_selection_strategy.is_empty() { + AccountSelectionStrategy::DEFAULT + } else { + d.account_selection_strategy.parse()? + }; + Ok(Self { download_dir: d.download_dir, start_minimized: d.start_minimized, notifications_enabled: d.notifications_enabled, @@ -258,6 +278,7 @@ impl From for AppConfig { dynamic_split_enabled: d.dynamic_split_enabled, dynamic_split_min_remaining_mb: d.dynamic_split_min_remaining_mb, history_retention_days: normalize_history_retention_days(d.history_retention_days), + account_selection_strategy, proxy_type: d.proxy_type, proxy_url: d.proxy_url, user_agent: d.user_agent, @@ -275,7 +296,7 @@ impl From for AppConfig { accent_color: d.accent_color, compact_mode: d.compact_mode, locale: d.locale, - } + }) } } @@ -579,4 +600,81 @@ mod tests { "user-cleared api_key must not be overwritten on subsequent loads" ); } + + /// Codex-flagged regression: a hand-edited `config.toml` carrying an + /// unknown `account_selection_strategy` previously fell back silently + /// to `best_traffic`, masking config corruption. The fix surfaces a + /// `StorageError` so the runtime can refuse to start with an invalid + /// persisted strategy instead of running with the wrong policy. + #[test] + fn test_get_config_rejects_unknown_persisted_account_selection_strategy() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("config.toml"); + std::fs::write( + &path, + "api_key = \"key\"\naccount_selection_strategy = \"not_a_strategy\"\n", + ) + .unwrap(); + + let store = TomlConfigStore::new(path, None, Some("default-key".to_string())); + let err = store + .get_config() + .expect_err("unknown persisted strategy must surface as a storage error"); + match err { + DomainError::StorageError(msg) => { + assert!( + msg.contains("invalid config") + && msg.contains("invalid account selection strategy"), + "unexpected storage error message: {msg}" + ); + } + other => panic!("expected StorageError, got {other:?}"), + } + } + + #[test] + fn test_get_config_accepts_known_persisted_account_selection_strategy() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("config.toml"); + std::fs::write( + &path, + "api_key = \"key\"\naccount_selection_strategy = \"round_robin\"\n", + ) + .unwrap(); + + let store = TomlConfigStore::new(path, None, Some("default-key".to_string())); + let config = store + .get_config() + .expect("known strategy value must round-trip through TryFrom"); + assert_eq!( + config.account_selection_strategy, + AccountSelectionStrategy::RoundRobin + ); + } + + /// Codex-flagged P1 regression: a legacy `config.toml` written before + /// the `account_selection_strategy` field existed has no key for it. + /// `#[serde(default)]` on `ConfigDto` makes the missing field + /// deserialize as the empty string. Without special-casing, the + /// strict `parse()` would fail and break startup for upgraded users. + /// The TOML store must surface `BestTraffic` for the empty-string + /// case while still rejecting non-empty typos. + #[test] + fn test_get_config_accepts_legacy_config_without_strategy_field() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("config.toml"); + // Pre-task-24 file: every existing field is present except the + // brand-new `account_selection_strategy`. + std::fs::write(&path, "api_key = \"legacy-key\"\n").unwrap(); + + let store = TomlConfigStore::new(path, None, Some("default-key".to_string())); + let config = store + .get_config() + .expect("legacy config without strategy field must load"); + assert_eq!( + config.account_selection_strategy, + AccountSelectionStrategy::DEFAULT, + "missing strategy field must hydrate as the default" + ); + } } diff --git a/src-tauri/src/adapters/driven/event/tauri_bridge.rs b/src-tauri/src/adapters/driven/event/tauri_bridge.rs index b943f913..457719b2 100644 --- a/src-tauri/src/adapters/driven/event/tauri_bridge.rs +++ b/src-tauri/src/adapters/driven/event/tauri_bridge.rs @@ -68,6 +68,8 @@ fn event_name(event: &DomainEvent) -> &'static str { DomainEvent::AccountValidationFailed { .. } => "account-validation-failed", DomainEvent::AccountsImported { .. } => "accounts-imported", DomainEvent::AccountsExported { .. } => "accounts-exported", + DomainEvent::NoAccountAvailable { .. } => "no-account-available", + DomainEvent::AccountSelected { .. } => "account-selected", } } @@ -196,6 +198,20 @@ fn event_payload(event: &DomainEvent) -> serde_json::Value { } DomainEvent::AccountsImported { count } => json!({ "count": count }), DomainEvent::AccountsExported { count } => json!({ "count": count }), + DomainEvent::NoAccountAvailable { service_name } => { + json!({ "serviceName": service_name }) + } + DomainEvent::AccountSelected { + id, + service_name, + strategy, + } => { + json!({ + "id": id.as_str(), + "serviceName": service_name, + "strategy": strategy, + }) + } } } diff --git a/src-tauri/src/adapters/driven/logging/download_log_bridge.rs b/src-tauri/src/adapters/driven/logging/download_log_bridge.rs index 665bcc34..6fa65bfe 100644 --- a/src-tauri/src/adapters/driven/logging/download_log_bridge.rs +++ b/src-tauri/src/adapters/driven/logging/download_log_bridge.rs @@ -137,7 +137,9 @@ fn record_download_event(store: &DownloadLogStore, event: &DomainEvent) { | DomainEvent::AccountValidated { .. } | DomainEvent::AccountValidationFailed { .. } | DomainEvent::AccountsImported { .. } - | DomainEvent::AccountsExported { .. } => {} + | DomainEvent::AccountsExported { .. } + | DomainEvent::NoAccountAvailable { .. } + | DomainEvent::AccountSelected { .. } => {} } } diff --git a/src-tauri/src/adapters/driving/tauri_ipc.rs b/src-tauri/src/adapters/driving/tauri_ipc.rs index 0822601c..635eff78 100644 --- a/src-tauri/src/adapters/driving/tauri_ipc.rs +++ b/src-tauri/src/adapters/driving/tauri_ipc.rs @@ -43,6 +43,7 @@ use crate::application::read_models::plugin_config_view::PluginConfigView; use crate::application::read_models::plugin_store_view::PluginStoreEntryDto; use crate::application::read_models::plugin_view::PluginViewDto; use crate::application::read_models::stats_view::{ModuleStatsDto, StatsViewDto}; +use crate::domain::error::DomainError; use crate::domain::model::account::{AccountId, AccountType}; use crate::domain::model::config::{AppConfig, ConfigPatch}; use crate::domain::model::download::{DownloadId, DownloadState}; @@ -850,6 +851,11 @@ pub struct SettingsDto { // History pub history_retention_days: i64, + // Accounts + /// Serialized as `"best_traffic" | "round_robin" | "manual"` to mirror + /// the snake_case enum convention used elsewhere in IPC payloads. + pub account_selection_strategy: String, + // Network pub proxy_type: String, pub proxy_url: Option, @@ -903,6 +909,7 @@ impl From for SettingsDto { dynamic_split_enabled: c.dynamic_split_enabled, dynamic_split_min_remaining_mb: c.dynamic_split_min_remaining_mb, history_retention_days: c.history_retention_days, + account_selection_strategy: c.account_selection_strategy.to_string(), proxy_type: c.proxy_type, proxy_url: c.proxy_url, user_agent: c.user_agent, @@ -924,7 +931,7 @@ impl From for SettingsDto { } } -#[derive(Debug, Clone, serde::Deserialize)] +#[derive(Debug, Clone, Default, serde::Deserialize)] #[serde(rename_all = "camelCase")] pub struct ConfigPatchDto { // General @@ -951,6 +958,11 @@ pub struct ConfigPatchDto { // History pub history_retention_days: Option, + // Accounts + /// Accepted values: `"best_traffic"`, `"round_robin"`, `"manual"`. + /// Unknown values are rejected by `ConfigPatch::try_from(ConfigPatchDto)`. + pub account_selection_strategy: Option, + // Network pub proxy_type: Option, pub proxy_url: Option>, @@ -977,9 +989,15 @@ pub struct ConfigPatchDto { pub locale: Option, } -impl From for ConfigPatch { - fn from(d: ConfigPatchDto) -> Self { - Self { +impl TryFrom for ConfigPatch { + type Error = String; + + fn try_from(d: ConfigPatchDto) -> Result { + let account_selection_strategy = match d.account_selection_strategy.as_deref() { + Some(raw) => Some(raw.parse().map_err(|e: DomainError| e.to_string())?), + None => None, + }; + Ok(Self { download_dir: d.download_dir, start_minimized: d.start_minimized, notifications_enabled: d.notifications_enabled, @@ -998,6 +1016,7 @@ impl From for ConfigPatch { dynamic_split_enabled: d.dynamic_split_enabled, dynamic_split_min_remaining_mb: d.dynamic_split_min_remaining_mb, history_retention_days: d.history_retention_days, + account_selection_strategy, proxy_type: d.proxy_type, proxy_url: d.proxy_url, user_agent: d.user_agent, @@ -1015,7 +1034,7 @@ impl From for ConfigPatch { accent_color: d.accent_color, compact_mode: d.compact_mode, locale: d.locale, - } + }) } } @@ -1056,7 +1075,7 @@ pub async fn settings_update( patch: ConfigPatchDto, ) -> Result { let cmd = UpdateConfigCommand { - patch: patch.into(), + patch: patch.try_into()?, }; state .command_bus @@ -3965,4 +3984,55 @@ mod tests { &format!("[INFO] line {}", DEFAULT_DOWNLOAD_LOG_LIMIT - 1), ); } + + /// `settings_update` must surface a validation error for unknown + /// `accountSelectionStrategy` values instead of silently dropping + /// them. Pinning the IPC contract: a typo from the UI can be + /// detected and surfaced to the user. + #[test] + fn test_config_patch_dto_rejects_unknown_account_selection_strategy() { + use super::{ConfigPatch, ConfigPatchDto}; + + let dto = ConfigPatchDto { + account_selection_strategy: Some("not_a_real_strategy".to_string()), + ..Default::default() + }; + + let result: Result = dto.try_into(); + let err = result.expect_err("unknown strategy must be rejected"); + assert!( + err.contains("invalid account selection strategy"), + "error message must mention the strategy validation: got {err}" + ); + } + + #[test] + fn test_config_patch_dto_accepts_known_account_selection_strategy() { + use super::{ConfigPatch, ConfigPatchDto}; + use crate::domain::model::account::AccountSelectionStrategy; + + let dto = ConfigPatchDto { + account_selection_strategy: Some("round_robin".to_string()), + ..Default::default() + }; + + let patch: ConfigPatch = dto.try_into().expect("known strategy must parse"); + assert_eq!( + patch.account_selection_strategy, + Some(AccountSelectionStrategy::RoundRobin) + ); + } + + #[test] + fn test_config_patch_dto_passes_through_when_strategy_is_none() { + use super::{ConfigPatch, ConfigPatchDto}; + + let dto = ConfigPatchDto { + account_selection_strategy: None, + ..Default::default() + }; + + let patch: ConfigPatch = dto.try_into().expect("None strategy is valid"); + assert!(patch.account_selection_strategy.is_none()); + } } diff --git a/src-tauri/src/application/command_bus.rs b/src-tauri/src/application/command_bus.rs index 02a7708c..aada26c6 100644 --- a/src-tauri/src/application/command_bus.rs +++ b/src-tauri/src/application/command_bus.rs @@ -5,6 +5,7 @@ use std::sync::Arc; +use crate::application::services::AccountSelector; use crate::domain::ports::driven::{ AccountCredentialStore, AccountRepository, AccountValidator, ArchiveExtractor, ChecksumComputer, ClipboardObserver, ConfigStore, CredentialStore, DownloadEngine, @@ -36,6 +37,7 @@ pub struct CommandBus { account_repo: Option>, account_credential_store: Option>, account_validator: Option>, + account_selector: Option>, passphrase_codec: Option>, /// Serializes queue-position allocation across handlers. Without this, /// two concurrent move-to-top/move-to-bottom/start-download calls can @@ -80,6 +82,7 @@ impl CommandBus { account_repo: None, account_credential_store: None, account_validator: None, + account_selector: None, passphrase_codec: None, queue_position_lock: tokio::sync::Mutex::new(()), } @@ -106,6 +109,13 @@ impl CommandBus { self } + /// Builder-style setter for the auto-selecting account dispatcher. + /// Optional so tests that don't exercise the dispatcher can omit it. + pub fn with_account_selector(mut self, selector: Arc) -> Self { + self.account_selector = Some(selector); + self + } + /// Builder-style setter for the passphrase codec used by the /// import / export commands. pub fn with_passphrase_codec(mut self, codec: Arc) -> Self { @@ -125,6 +135,10 @@ impl CommandBus { self.account_validator.as_deref() } + pub fn account_selector(&self) -> Option<&AccountSelector> { + self.account_selector.as_deref() + } + pub fn passphrase_codec(&self) -> Option<&dyn PassphraseCodec> { self.passphrase_codec.as_deref() } @@ -260,6 +274,27 @@ impl CommandBus { pub(crate) fn url_opener_arc(&self) -> Option> { self.url_opener.clone() } + + /// Convenience entry-point for the link-grabber and download flows. + /// + /// When an `AccountSelector` is wired AND the configured strategy is + /// honoured, returns the chosen account for `service_name`. When no + /// selector is wired (e.g. test fixtures) returns `Ok(None)`. PRD + /// §6.4 — the strategy is read from the live `AppConfig` at every + /// call so a runtime change to `account_selection_strategy` is + /// honoured without restart. + pub fn resolve_account_for( + &self, + service_name: &str, + ) -> Result, crate::application::error::AppError> + { + let selector = match self.account_selector() { + Some(s) => s, + None => return Ok(None), + }; + let strategy = self.config_store.get_config()?.account_selection_strategy; + selector.select_best(service_name, strategy) + } } #[cfg(test)] @@ -658,4 +693,94 @@ mod tests { fn assert_send_sync() {} assert_send_sync::(); } + + /// `resolve_account_for` must propagate `ConfigStore::get_config()` + /// failures instead of silently falling back to the default strategy. + /// A corrupt or unreadable config previously forced `BestTraffic` + /// even when the user had picked `RoundRobin` / `Manual`. The fix: + /// surface the error to the caller via `?`. + #[test] + fn test_resolve_account_for_propagates_config_store_failure() { + use crate::application::services::AccountSelector; + use crate::domain::model::account::{Account, AccountId, AccountSelectionStrategy}; + use crate::domain::ports::driven::AccountRepository; + use crate::domain::ports::driven::clock::Clock; + use crate::domain::ports::driven::event_bus::EventBus; + + // Bus + clock + repo stand-ins — none of them are exercised + // because the failing config store short-circuits the call. + struct StubBus; + impl EventBus for StubBus { + fn publish(&self, _: DomainEvent) {} + fn subscribe(&self, _: Box) {} + } + struct ZeroClock; + impl Clock for ZeroClock { + fn now_unix_secs(&self) -> u64 { + 0 + } + } + struct EmptyRepo; + impl AccountRepository for EmptyRepo { + fn find_by_id(&self, _: &AccountId) -> Result, DomainError> { + Ok(None) + } + fn save(&self, _: &Account) -> Result<(), DomainError> { + Ok(()) + } + fn list(&self) -> Result, DomainError> { + Ok(vec![]) + } + fn list_by_service(&self, _: &str) -> Result, DomainError> { + Ok(vec![]) + } + fn delete(&self, _: &AccountId) -> Result<(), DomainError> { + Ok(()) + } + } + + struct FailingConfigStore; + impl ConfigStore for FailingConfigStore { + fn get_config(&self) -> Result { + Err(DomainError::ValidationError("config corrupted".into())) + } + fn update_config(&self, _: ConfigPatch) -> Result { + Err(DomainError::ValidationError("config corrupted".into())) + } + } + + let bus: Arc = Arc::new(StubBus); + let clock: Arc = Arc::new(ZeroClock); + let repo: Arc = Arc::new(EmptyRepo); + let selector = AccountSelector::new(repo, bus, clock); + + let command_bus = CommandBus::new( + Arc::new(MockDownloadRepo::new()), + Arc::new(MockDownloadEngine::new()), + Arc::new(MockEventBus::new()), + Arc::new(MockFileStorage::new()), + Arc::new(MockHttpClient), + Arc::new(MockPluginLoader::new()), + Arc::new(FailingConfigStore), + Arc::new(MockCredentialStore::new()), + Arc::new(MockClipboardObserver::new()), + Arc::new(FakeArchiveExtractor), + Arc::new(crate::application::test_support::NoopHistoryRepo), + None, + ) + .with_account_selector(selector); + + // `_` keeps the strategy enum in scope for the contract proof — + // even when the user picked anything, the failing store must + // bubble up before the strategy is read. + let _ = AccountSelectionStrategy::RoundRobin; + + let err = command_bus + .resolve_account_for("Uploaded") + .expect_err("config-store failure must propagate"); + assert!(matches!( + err, + crate::application::error::AppError::Domain(DomainError::ValidationError(_)) + )); + } } diff --git a/src-tauri/src/application/commands/resolve_links.rs b/src-tauri/src/application/commands/resolve_links.rs index 86ffffb0..7a8de198 100644 --- a/src-tauri/src/application/commands/resolve_links.rs +++ b/src-tauri/src/application/commands/resolve_links.rs @@ -88,6 +88,12 @@ impl CommandBus { Ok(Some(info)) => info.name().to_string(), _ => "core-http".to_string(), }; + // Account dispatcher hook (task 24): plugins that need + // credentials must request them via + // `CommandBus::resolve_account_for(service_name)` so the + // selector strategy from `AppConfig` is honoured. Resolve + // is best-effort metadata only — no account is fetched here + // to avoid emitting `AccountSelected` once per probed URL. let is_media = is_media_url(url); let media_type = if is_media { diff --git a/src-tauri/src/application/services/account_selector.rs b/src-tauri/src/application/services/account_selector.rs new file mode 100644 index 00000000..4b75bff9 --- /dev/null +++ b/src-tauri/src/application/services/account_selector.rs @@ -0,0 +1,658 @@ +//! `AccountSelector` — auto-pick the best account per service. +//! +//! PRD §6.4 — when several accounts exist for the same hoster / debrid +//! service, the engine asks the selector for the one to use *now*. The +//! selector applies the strategy currently set in `AppConfig`: +//! +//! - `BestTraffic` (default): rank candidates by *enabled* → *not expired* +//! → most `traffic_left` (unlimited > finite) → most recent +//! `last_validated`. +//! - `RoundRobin`: alternate over enabled, non-expired candidates ordered +//! by id. Each `select_best(service)` advances a per-service cursor so +//! load is spread across accounts even when all of them have the same +//! traffic profile. +//! - `Manual`: today an alias of `BestTraffic`. The pinning UI is a +//! future iteration; the alias keeps the config schema forward- +//! compatible and is exercised by tests so a regression cannot quietly +//! change the fallback. +//! +//! Reads always go straight to `AccountRepository::list_by_service`; the +//! selector intentionally caches nothing. An earlier revision kept a +//! per-service candidate cache invalidated by domain events, but the +//! production `TokioEventBus` dispatches subscribers on a spawned task +//! (`broadcast::recv` + `tokio::spawn`), so a `select_best` call landing +//! between `bus.publish(AccountUpdated)` and the subscriber firing can +//! observe stale rows. SQLite reads are cheap for the row counts the +//! selector sees in practice (≤ a few dozen accounts per service), so the +//! cache traded correctness for negligible savings. + +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +use crate::application::error::AppError; +use crate::domain::event::DomainEvent; +use crate::domain::model::account::{Account, AccountSelectionStrategy}; +use crate::domain::ports::driven::AccountRepository; +use crate::domain::ports::driven::clock::Clock; +use crate::domain::ports::driven::event_bus::EventBus; + +/// Auto-selects the best `Account` for a hoster / debrid service. +/// +/// `select_best` is the single entry point. It MUST be called by every +/// flow that wants to use credentials (resolve_links, plugin +/// `download_to_file` adapters, debrid resolvers) so the selection +/// strategy is honoured uniformly. +pub struct AccountSelector { + repo: Arc, + event_bus: Arc, + clock: Arc, + /// Per-service round-robin cursor. Used only for the `RoundRobin` + /// strategy; everything else is stateless. + rr_cursor: Mutex>, +} + +impl AccountSelector { + pub fn new( + repo: Arc, + event_bus: Arc, + clock: Arc, + ) -> Arc { + Arc::new(Self { + repo, + event_bus, + clock, + rr_cursor: Mutex::new(HashMap::new()), + }) + } + + /// Pick the best candidate for `service_name` according to the + /// requested `strategy`. Returns `None` (and emits + /// `DomainEvent::NoAccountAvailable`) when no enabled, non-expired + /// account is available for this service. + pub fn select_best( + &self, + service_name: &str, + strategy: AccountSelectionStrategy, + ) -> Result, AppError> { + let candidates = self.repo.list_by_service(service_name)?; + let now_ms = self.now_ms(); + let eligible: Vec<&Account> = candidates + .iter() + .filter(|a| a.is_enabled() && !a.is_expired(now_ms)) + .collect(); + if eligible.is_empty() { + self.event_bus.publish(DomainEvent::NoAccountAvailable { + service_name: service_name.to_string(), + }); + return Ok(None); + } + let chosen = match strategy { + AccountSelectionStrategy::BestTraffic | AccountSelectionStrategy::Manual => { + pick_best_traffic(&eligible) + } + AccountSelectionStrategy::RoundRobin => { + self.pick_round_robin(service_name, &eligible)? + } + }; + let account = chosen.cloned(); + if let Some(ref acc) = account { + self.event_bus.publish(DomainEvent::AccountSelected { + id: acc.id().clone(), + service_name: service_name.to_string(), + strategy: strategy.to_string(), + }); + } + Ok(account) + } + + /// Returns the next round-robin candidate, or `None` when `eligible` + /// is empty. Surfaces a poisoned cursor mutex as `AppError::Validation` + /// instead of folding it into `Ok(None)`: that variant of `select_best` + /// is reserved for "zero eligible accounts" and must stay distinguishable + /// from internal-state corruption so callers can react to a real failure. + fn pick_round_robin<'a>( + &self, + key: &str, + eligible: &[&'a Account], + ) -> Result, AppError> { + if eligible.is_empty() { + return Ok(None); + } + let mut sorted = eligible.to_vec(); + sorted.sort_by(|a, b| a.id().as_str().cmp(b.id().as_str())); + let mut guard = self + .rr_cursor + .lock() + .map_err(|_| AppError::Validation("round-robin cursor mutex poisoned".to_string()))?; + let cursor = guard.entry(key.to_string()).or_insert(0); + let pick = sorted[*cursor % sorted.len()]; + *cursor = cursor.wrapping_add(1); + Ok(Some(pick)) + } + + fn now_ms(&self) -> u64 { + self.clock.now_unix_secs().saturating_mul(1_000) + } +} + +/// Rank rule for `BestTraffic`: +/// 1. Unlimited traffic (`traffic_left == None`) wins over any finite value. +/// 2. Among finite-traffic accounts, more `traffic_left` wins. +/// 3. Tiebreaker: most recent `last_validated` (None ranks last). +/// 4. Final tiebreaker: id ascending so the choice is deterministic. +fn pick_best_traffic<'a>(eligible: &[&'a Account]) -> Option<&'a Account> { + eligible.iter().copied().max_by(|a, b| { + let traffic = traffic_rank(a).cmp(&traffic_rank(b)); + if traffic != std::cmp::Ordering::Equal { + return traffic; + } + let validated = a.last_validated().cmp(&b.last_validated()); + if validated != std::cmp::Ordering::Equal { + return validated; + } + // Reverse so the smaller id wins (max_by returns the greatest). + b.id().as_str().cmp(a.id().as_str()) + }) +} + +fn traffic_rank(a: &Account) -> TrafficRank { + match a.traffic_left() { + None => TrafficRank::Unlimited, + Some(bytes) => TrafficRank::Finite(bytes), + } +} + +/// Total ordering for `traffic_left`. `Unlimited` ranks above any +/// `Finite(_)` regardless of size — an unlimited premium plan is always +/// preferable to a quota-bound one. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +enum TrafficRank { + Finite(u64), + Unlimited, +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::domain::error::DomainError; + use crate::domain::model::account::{AccountId, AccountType}; + use std::sync::Mutex as StdMutex; + + // --- Inline mocks --- + + struct InMemoryRepo { + accounts: StdMutex>, + } + + impl InMemoryRepo { + fn new(accounts: Vec) -> Self { + Self { + accounts: StdMutex::new(accounts), + } + } + } + + impl AccountRepository for InMemoryRepo { + fn find_by_id(&self, id: &AccountId) -> Result, DomainError> { + Ok(self + .accounts + .lock() + .unwrap() + .iter() + .find(|a| a.id() == id) + .cloned()) + } + + fn save(&self, account: &Account) -> Result<(), DomainError> { + let mut guard = self.accounts.lock().unwrap(); + if let Some(existing) = guard.iter_mut().find(|a| a.id() == account.id()) { + *existing = account.clone(); + } else { + guard.push(account.clone()); + } + Ok(()) + } + + fn list(&self) -> Result, DomainError> { + Ok(self.accounts.lock().unwrap().clone()) + } + + fn list_by_service(&self, service_name: &str) -> Result, DomainError> { + Ok(self + .accounts + .lock() + .unwrap() + .iter() + .filter(|a| a.service_name() == service_name) + .cloned() + .collect()) + } + + fn delete(&self, id: &AccountId) -> Result<(), DomainError> { + self.accounts.lock().unwrap().retain(|a| a.id() != id); + Ok(()) + } + } + + type EventSubscriber = Box; + + struct CollectingBus { + events: StdMutex>, + subscribers: StdMutex>, + } + + impl CollectingBus { + fn new() -> Self { + Self { + events: StdMutex::new(Vec::new()), + subscribers: StdMutex::new(Vec::new()), + } + } + + fn events(&self) -> Vec { + self.events.lock().unwrap().clone() + } + } + + impl EventBus for CollectingBus { + fn publish(&self, event: DomainEvent) { + self.events.lock().unwrap().push(event.clone()); + for handler in self.subscribers.lock().unwrap().iter() { + handler(&event); + } + } + + fn subscribe(&self, handler: Box) { + self.subscribers.lock().unwrap().push(handler); + } + } + + struct FixedClock(u64); + + impl Clock for FixedClock { + fn now_unix_secs(&self) -> u64 { + self.0 + } + } + + fn account( + id: &str, + service: &str, + traffic_left: Option, + valid_until_ms: Option, + last_validated_ms: Option, + enabled: bool, + ) -> Account { + Account::reconstruct( + AccountId::new(id), + service.to_string(), + format!("user-{id}"), + AccountType::Premium, + enabled, + traffic_left, + None, + valid_until_ms, + last_validated_ms, + 0, + ) + } + + fn build_selector( + accounts: Vec, + now_secs: u64, + ) -> (Arc, Arc) { + let repo = Arc::new(InMemoryRepo::new(accounts)); + let bus = Arc::new(CollectingBus::new()); + let clock = Arc::new(FixedClock(now_secs)); + let selector = AccountSelector::new(repo, bus.clone(), clock); + (selector, bus) + } + + // --- Acceptance criterion 1: 1 expired, 1 low traffic, 1 full → full traffic wins --- + #[test] + fn test_select_best_returns_account_with_most_traffic_left() { + let now_ms = 2_000_000_000_000; + let now_secs = now_ms / 1_000; + let expired = account( + "a-expired", + "Uploaded", + Some(50_000_000_000), + Some(now_ms - 1), + Some(now_ms - 60_000), + true, + ); + let low = account( + "b-low", + "Uploaded", + Some(1_000_000_000), + Some(now_ms + 86_400_000), + Some(now_ms - 60_000), + true, + ); + let full = account( + "c-full", + "Uploaded", + Some(50_000_000_000), + Some(now_ms + 86_400_000), + Some(now_ms - 60_000), + true, + ); + + let (selector, bus) = build_selector(vec![expired, low, full], now_secs); + + let chosen = selector + .select_best("Uploaded", AccountSelectionStrategy::BestTraffic) + .expect("select ok") + .expect("an account is eligible"); + assert_eq!(chosen.id().as_str(), "c-full"); + + let events = bus.events(); + assert!(events.iter().any(|e| matches!( + e, + DomainEvent::AccountSelected { id, service_name, strategy } + if id.as_str() == "c-full" && service_name == "Uploaded" && strategy == "best_traffic" + ))); + } + + // --- Acceptance criterion 2: all expired → None + NoAccountAvailable --- + #[test] + fn test_select_best_returns_none_when_all_expired_and_emits_event() { + let now_ms = 2_000_000_000_000; + let now_secs = now_ms / 1_000; + let a = account("a", "Uploaded", Some(100), Some(now_ms - 10), None, true); + let b = account("b", "Uploaded", None, Some(now_ms - 5), None, true); + + let (selector, bus) = build_selector(vec![a, b], now_secs); + + let chosen = selector + .select_best("Uploaded", AccountSelectionStrategy::BestTraffic) + .expect("select ok"); + assert!(chosen.is_none()); + + let events = bus.events(); + assert!(events.iter().any(|e| matches!( + e, + DomainEvent::NoAccountAvailable { service_name } if service_name == "Uploaded" + ))); + assert!( + !events + .iter() + .any(|e| matches!(e, DomainEvent::AccountSelected { .. })), + "must NOT emit AccountSelected when nothing was selected" + ); + } + + // --- Acceptance criterion 3: comparative ranking table --- + // + // Verifies the documented rank precedence for `BestTraffic`: + // unlimited > finite, then most-traffic, then most-recently-validated, + // then smallest id. Each row pins one rule. + #[test] + fn test_select_best_unlimited_traffic_beats_any_finite() { + let now_ms = 2_000_000_000_000; + let now_secs = now_ms / 1_000; + let huge_finite = account( + "huge", + "S", + Some(u64::MAX), + Some(now_ms + 1), + Some(now_ms), + true, + ); + let unlimited = account("inf", "S", None, Some(now_ms + 1), Some(now_ms), true); + + let (selector, _bus) = build_selector(vec![huge_finite, unlimited], now_secs); + + let chosen = selector + .select_best("S", AccountSelectionStrategy::BestTraffic) + .unwrap() + .unwrap(); + assert_eq!(chosen.id().as_str(), "inf"); + } + + #[test] + fn test_select_best_uses_last_validated_to_break_traffic_tie() { + let now_ms = 2_000_000_000_000; + let now_secs = now_ms / 1_000; + let stale = account( + "stale", + "S", + Some(10_000), + Some(now_ms + 1), + Some(now_ms - 60_000), + true, + ); + let fresh = account( + "fresh", + "S", + Some(10_000), + Some(now_ms + 1), + Some(now_ms - 100), + true, + ); + + let (selector, _bus) = build_selector(vec![stale, fresh], now_secs); + + let chosen = selector + .select_best("S", AccountSelectionStrategy::BestTraffic) + .unwrap() + .unwrap(); + assert_eq!(chosen.id().as_str(), "fresh"); + } + + #[test] + fn test_select_best_breaks_complete_tie_with_smallest_id() { + let now_ms = 2_000_000_000_000; + let now_secs = now_ms / 1_000; + let a = account("aaa", "S", Some(5), Some(now_ms + 1), None, true); + let b = account("bbb", "S", Some(5), Some(now_ms + 1), None, true); + + let (selector, _bus) = build_selector(vec![a, b], now_secs); + + let chosen = selector + .select_best("S", AccountSelectionStrategy::BestTraffic) + .unwrap() + .unwrap(); + assert_eq!( + chosen.id().as_str(), + "aaa", + "deterministic id tiebreaker (smallest id wins)" + ); + } + + #[test] + fn test_select_best_skips_disabled_accounts() { + let now_ms = 2_000_000_000_000; + let now_secs = now_ms / 1_000; + let disabled_full = account( + "disabled", + "S", + Some(u64::MAX), + Some(now_ms + 1), + None, + false, + ); + let enabled_low = account("enabled", "S", Some(1), Some(now_ms + 1), None, true); + + let (selector, _bus) = build_selector(vec![disabled_full, enabled_low], now_secs); + + let chosen = selector + .select_best("S", AccountSelectionStrategy::BestTraffic) + .unwrap() + .unwrap(); + assert_eq!(chosen.id().as_str(), "enabled"); + } + + // --- Acceptance criterion 4: RoundRobin alternance --- + #[test] + fn test_round_robin_alternates_across_eligible_accounts() { + let now_ms = 2_000_000_000_000; + let now_secs = now_ms / 1_000; + let a = account("acc-1", "S", Some(100), Some(now_ms + 1), None, true); + let b = account("acc-2", "S", Some(200), Some(now_ms + 1), None, true); + let c = account("acc-3", "S", Some(300), Some(now_ms + 1), None, true); + + let (selector, _bus) = build_selector(vec![a, b, c], now_secs); + + let mut picked = Vec::new(); + for _ in 0..6 { + let chosen = selector + .select_best("S", AccountSelectionStrategy::RoundRobin) + .unwrap() + .unwrap(); + picked.push(chosen.id().as_str().to_string()); + } + assert_eq!( + picked, + vec!["acc-1", "acc-2", "acc-3", "acc-1", "acc-2", "acc-3"], + "round-robin must rotate in id order and wrap around" + ); + } + + #[test] + fn test_round_robin_emits_account_selected_with_strategy_name() { + let now_ms = 2_000_000_000_000; + let now_secs = now_ms / 1_000; + let a = account("acc-1", "S", Some(100), Some(now_ms + 1), None, true); + + let (selector, bus) = build_selector(vec![a], now_secs); + + selector + .select_best("S", AccountSelectionStrategy::RoundRobin) + .unwrap() + .unwrap(); + + let events = bus.events(); + assert!(events.iter().any(|e| matches!( + e, + DomainEvent::AccountSelected { strategy, .. } if strategy == "round_robin" + ))); + } + + #[test] + fn test_manual_strategy_falls_back_to_best_traffic_today() { + // Manual pinning is a future iteration; until then it must NOT + // crash and must produce a deterministic pick (currently identical + // to BestTraffic). Exercising it here freezes the behaviour so a + // future change cannot quietly drop the fallback. + let now_ms = 2_000_000_000_000; + let now_secs = now_ms / 1_000; + let low = account("low", "S", Some(1), Some(now_ms + 1), None, true); + let high = account("high", "S", Some(999), Some(now_ms + 1), None, true); + + let (selector, _bus) = build_selector(vec![low, high], now_secs); + + let chosen = selector + .select_best("S", AccountSelectionStrategy::Manual) + .unwrap() + .unwrap(); + assert_eq!(chosen.id().as_str(), "high"); + } + + // Repo-fresh contract: `select_best` reads `list_by_service` on every + // call, so any mutation visible in the repo surfaces on the next pick + // without needing an event-bus round trip. This is the regression + // pinning Codex's "synchronous-with-mutation" finding — the previous + // event-driven cache could stay stale between `bus.publish(...)` and + // the spawned subscriber firing under `TokioEventBus`. + #[test] + fn test_select_best_always_reflects_current_repo_state() { + let now_ms = 2_000_000_000_000; + let now_secs = now_ms / 1_000; + let initial = account("a", "S", Some(10), Some(now_ms + 1), None, true); + + let repo = Arc::new(InMemoryRepo::new(vec![initial])); + let bus = Arc::new(CollectingBus::new()); + let clock = Arc::new(FixedClock(now_secs)); + let selector = AccountSelector::new(repo.clone(), bus, clock); + + let first = selector + .select_best("S", AccountSelectionStrategy::BestTraffic) + .unwrap() + .unwrap(); + assert_eq!(first.traffic_left(), Some(10)); + + // Repo mutates with NO event published — no subscriber to notify. + // The next call must still see the new value because the selector + // does not cache. + let mutated = account("a", "S", Some(9_000_000), Some(now_ms + 1), None, true); + repo.save(&mutated).unwrap(); + + let after = selector + .select_best("S", AccountSelectionStrategy::BestTraffic) + .unwrap() + .unwrap(); + assert_eq!( + after.traffic_left(), + Some(9_000_000), + "selector must always read live repo state, not a snapshot" + ); + } + + /// `service_name` lookup is whatever the repo does — `list_by_service` + /// is case-sensitive on the SQLite `service_name` column, so a + /// case-mismatched caller surfaces the same `None` it would on a + /// fresh repo. + #[test] + fn test_select_best_is_case_sensitive_on_service_name() { + let now_ms = 2_000_000_000_000; + let now_secs = now_ms / 1_000; + let a = account("a", "Uploaded", Some(10), Some(now_ms + 1), None, true); + + let repo = Arc::new(InMemoryRepo::new(vec![a])); + let bus = Arc::new(CollectingBus::new()); + let clock = Arc::new(FixedClock(now_secs)); + let selector = AccountSelector::new(repo, bus, clock); + + let r1 = selector + .select_best("Uploaded", AccountSelectionStrategy::BestTraffic) + .unwrap() + .unwrap(); + let r2 = selector + .select_best("UPLOADED", AccountSelectionStrategy::BestTraffic) + .unwrap(); + assert_eq!(r1.id().as_str(), "a"); + assert!(r2.is_none(), "case-mismatched service name has no rows"); + } + + /// CodeRabbit / cubic-flagged regression: a poisoned `rr_cursor` + /// mutex used to fold into `Ok(None)` because `lock().ok()?` swallowed + /// the `PoisonError`. The contract reserves `Ok(None)` for + /// "zero eligible accounts", so we must surface the failure as + /// `AppError::Validation` instead — otherwise callers cannot tell + /// "no candidate" from "internal state corrupted" and miss the + /// `NoAccountAvailable` code path that should never have fired. + #[test] + fn test_pick_round_robin_returns_err_on_poisoned_cursor() { + use std::thread; + + let now_ms = 2_000_000_000_000; + let now_secs = now_ms / 1_000; + let a = account("a", "S", Some(1), Some(now_ms + 1), None, true); + let b = account("b", "S", Some(1), Some(now_ms + 1), None, true); + + let (selector, _bus) = build_selector(vec![a, b], now_secs); + + // Poison the rr_cursor mutex by panicking inside a held guard. + let selector_clone = selector.clone(); + let _ = thread::spawn(move || { + let _guard = selector_clone + .rr_cursor + .lock() + .expect("first lock cannot be poisoned"); + panic!("intentional panic to poison the cursor"); + }) + .join(); + + let result = selector.select_best("S", AccountSelectionStrategy::RoundRobin); + match result { + Err(AppError::Validation(msg)) => { + assert!( + msg.contains("round-robin cursor mutex poisoned"), + "unexpected error message: {msg}" + ); + } + other => panic!("poisoned cursor must surface as AppError::Validation, got {other:?}"), + } + } +} diff --git a/src-tauri/src/application/services/mod.rs b/src-tauri/src/application/services/mod.rs index c954ca69..947469c1 100644 --- a/src-tauri/src/application/services/mod.rs +++ b/src-tauri/src/application/services/mod.rs @@ -1,3 +1,4 @@ +pub mod account_selector; pub mod checksum_validator; pub mod engine_config_bridge; pub mod history_backfill; @@ -5,6 +6,7 @@ pub mod queue_config_bridge; pub mod queue_manager; pub mod startup_recovery; +pub use account_selector::AccountSelector; pub use checksum_validator::{ChecksumOutcome, ChecksumValidatorService}; pub use engine_config_bridge::subscribe_engine_to_config; pub use history_backfill::backfill_history_for_completed_downloads; diff --git a/src-tauri/src/domain/event.rs b/src-tauri/src/domain/event.rs index 8d986035..9ef33f44 100644 --- a/src-tauri/src/domain/event.rs +++ b/src-tauri/src/domain/event.rs @@ -274,6 +274,23 @@ pub enum DomainEvent { AccountsExported { count: u32, }, + /// Emitted by `AccountSelector::select_best` when no enabled, + /// non-expired account exists for the requested service. The + /// scheduler / link-grabber can react by falling back to a free + /// hoster path or surfacing a UI hint. + NoAccountAvailable { + service_name: String, + }, + /// Emitted by `AccountSelector::select_best` whenever it picks an + /// account. Carries the strategy name so the audit / telemetry + /// layer can detect if a deployment is using anything other than + /// the default `BestTraffic`. + AccountSelected { + id: AccountId, + service_name: String, + /// One of `"best_traffic"`, `"round_robin"`, `"manual"`. + strategy: String, + }, } #[cfg(test)] diff --git a/src-tauri/src/domain/model/account.rs b/src-tauri/src/domain/model/account.rs index e34a16ba..f786ab96 100644 --- a/src-tauri/src/domain/model/account.rs +++ b/src-tauri/src/domain/model/account.rs @@ -55,6 +55,54 @@ impl FromStr for AccountType { } } +/// Strategy used by `AccountSelector` to pick the next account when several +/// exist for the same service. `BestTraffic` is the default. +/// +/// PRD §6.4 — "Auto-select du meilleur compte disponible". +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AccountSelectionStrategy { + /// Pick the enabled, non-expired account with the most traffic left. + /// Unlimited traffic (`None`) ranks above any finite traffic value. + BestTraffic, + /// Round-robin across enabled, non-expired candidates ordered by id. + /// Each `select_best(service)` call advances the cursor for that service. + RoundRobin, + /// Defer to a user-pinned account; if none is pinned, fall back to + /// `BestTraffic`. Pinning UI is a future iteration; today this acts + /// as a no-op alias of `BestTraffic`. + Manual, +} + +impl AccountSelectionStrategy { + pub const DEFAULT: Self = AccountSelectionStrategy::BestTraffic; +} + +impl fmt::Display for AccountSelectionStrategy { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let s = match self { + AccountSelectionStrategy::BestTraffic => "best_traffic", + AccountSelectionStrategy::RoundRobin => "round_robin", + AccountSelectionStrategy::Manual => "manual", + }; + f.write_str(s) + } +} + +impl FromStr for AccountSelectionStrategy { + type Err = DomainError; + + fn from_str(s: &str) -> Result { + match s { + "best_traffic" => Ok(AccountSelectionStrategy::BestTraffic), + "round_robin" => Ok(AccountSelectionStrategy::RoundRobin), + "manual" => Ok(AccountSelectionStrategy::Manual), + other => Err(DomainError::ValidationError(format!( + "invalid account selection strategy: {other}" + ))), + } + } +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct Account { id: AccountId, @@ -394,6 +442,33 @@ mod tests { assert_eq!(id.as_str(), "xyz-42"); } + #[test] + fn test_account_selection_strategy_round_trip_via_string() { + for s in [ + AccountSelectionStrategy::BestTraffic, + AccountSelectionStrategy::RoundRobin, + AccountSelectionStrategy::Manual, + ] { + let rendered = s.to_string(); + let parsed: AccountSelectionStrategy = rendered.parse().expect("round trip"); + assert_eq!(parsed, s); + } + } + + #[test] + fn test_account_selection_strategy_default_is_best_traffic() { + assert_eq!( + AccountSelectionStrategy::DEFAULT, + AccountSelectionStrategy::BestTraffic + ); + } + + #[test] + fn test_account_selection_strategy_from_str_rejects_unknown() { + let result: Result = "best".parse(); + assert!(matches!(result, Err(DomainError::ValidationError(_)))); + } + #[test] fn test_account_reconstruct_preserves_all_fields() { let acc = Account::reconstruct( diff --git a/src-tauri/src/domain/model/config.rs b/src-tauri/src/domain/model/config.rs index 053055b4..52433509 100644 --- a/src-tauri/src/domain/model/config.rs +++ b/src-tauri/src/domain/model/config.rs @@ -3,6 +3,8 @@ //! Used by `ConfigStore` port for reading and updating settings. //! These types live in the domain because the port traits reference them. +use crate::domain::model::account::AccountSelectionStrategy; + /// Application-wide configuration. /// /// Represents the full config as stored in `config.toml`. @@ -44,6 +46,12 @@ pub struct AppConfig { /// PRD §6.8 / §7.9 — hard delete decision (privacy by default). pub history_retention_days: i64, + // ── Accounts ───────────────────────────────────────────────────── + /// Strategy used by `AccountSelector` when several accounts exist + /// for the same service. PRD §6.4 — "Auto-select du meilleur + /// compte disponible". + pub account_selection_strategy: AccountSelectionStrategy, + // ── Network ────────────────────────────────────────────────────── /// `"none"`, `"http"`, or `"socks5"`. pub proxy_type: String, @@ -109,6 +117,9 @@ impl Default for AppConfig { // History history_retention_days: 30, + // Accounts + account_selection_strategy: AccountSelectionStrategy::DEFAULT, + // Network proxy_type: "none".to_string(), proxy_url: None, @@ -171,6 +182,9 @@ pub struct ConfigPatch { // History pub history_retention_days: Option, + // Accounts + pub account_selection_strategy: Option, + // Network pub proxy_type: Option, pub proxy_url: Option>, @@ -290,6 +304,11 @@ pub fn apply_patch(config: &mut AppConfig, patch: &ConfigPatch) { config.history_retention_days = normalize_history_retention_days(v); } + // Accounts + if let Some(v) = patch.account_selection_strategy { + config.account_selection_strategy = v; + } + // Network if let Some(ref v) = patch.proxy_type { config.proxy_type = v.clone(); @@ -490,6 +509,28 @@ mod tests { assert_eq!(normalize_history_retention_days(i64::MIN), 0); } + #[test] + fn test_default_account_selection_strategy_is_best_traffic() { + assert_eq!( + AppConfig::default().account_selection_strategy, + AccountSelectionStrategy::BestTraffic + ); + } + + #[test] + fn test_apply_patch_updates_account_selection_strategy() { + let mut config = AppConfig::default(); + let patch = ConfigPatch { + account_selection_strategy: Some(AccountSelectionStrategy::RoundRobin), + ..Default::default() + }; + apply_patch(&mut config, &patch); + assert_eq!( + config.account_selection_strategy, + AccountSelectionStrategy::RoundRobin + ); + } + #[test] fn test_normalize_history_retention_days_passes_through_non_negative() { for &v in &HISTORY_RETENTION_PRESETS_DAYS {