domain crate code quality cleanup
strip all comments, extract tests to tests/ dirs, remove #[allow(clippy::...)], extract magic numbers to constants, refactor schedule engine private methods to use param structs, add Default impls, clippy.toml for persistence constructors
This commit is contained in:
@@ -2,11 +2,6 @@ use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||
use std::fmt;
|
||||
use thiserror::Error;
|
||||
|
||||
// ============================================================================
|
||||
// Validation Error
|
||||
// ============================================================================
|
||||
|
||||
/// Errors that occur when parsing/validating value objects
|
||||
#[derive(Debug, Error, Clone, PartialEq, Eq)]
|
||||
#[non_exhaustive]
|
||||
pub enum ValidationError {
|
||||
@@ -26,16 +21,10 @@ pub enum ValidationError {
|
||||
SecretTooShort { min: usize, actual: usize },
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Email (using email_address crate for RFC-compliant validation)
|
||||
// ============================================================================
|
||||
|
||||
/// A validated email address using RFC-compliant validation.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub struct Email(email_address::EmailAddress);
|
||||
|
||||
impl Email {
|
||||
/// Create a new validated email address
|
||||
pub fn new(value: impl AsRef<str>) -> Result<Self, ValidationError> {
|
||||
let value = value.as_ref().trim().to_lowercase();
|
||||
let addr: email_address::EmailAddress = value
|
||||
@@ -44,7 +33,6 @@ impl Email {
|
||||
Ok(Self(addr))
|
||||
}
|
||||
|
||||
/// Get the inner value
|
||||
pub fn into_inner(self) -> String {
|
||||
self.0.to_string()
|
||||
}
|
||||
@@ -91,17 +79,9 @@ impl<'de> Deserialize<'de> for Email {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Password
|
||||
// ============================================================================
|
||||
|
||||
/// A validated password input (NOT the hash).
|
||||
///
|
||||
/// Enforces minimum length of 8 characters.
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub struct Password(String);
|
||||
|
||||
/// Minimum password length (NIST recommendation)
|
||||
pub const MIN_PASSWORD_LENGTH: usize = 8;
|
||||
|
||||
impl Password {
|
||||
@@ -129,7 +109,7 @@ impl AsRef<str> for Password {
|
||||
}
|
||||
}
|
||||
|
||||
// Intentionally hide password content in Debug
|
||||
// Intentionally hidden in Debug
|
||||
impl fmt::Debug for Password {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "Password(***)")
|
||||
@@ -159,69 +139,8 @@ impl<'de> Deserialize<'de> for Password {
|
||||
}
|
||||
}
|
||||
|
||||
// Note: Password should NOT implement Serialize to prevent accidental exposure
|
||||
|
||||
// ============================================================================
|
||||
// Tests
|
||||
// ============================================================================
|
||||
// Password must NOT implement Serialize to prevent accidental exposure
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
mod email_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_valid_email() {
|
||||
assert!(Email::new("user@example.com").is_ok());
|
||||
assert!(Email::new("USER@EXAMPLE.COM").is_ok()); // Should lowercase
|
||||
assert!(Email::new(" user@example.com ").is_ok()); // Should trim
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_email_normalizes() {
|
||||
let email = Email::new(" USER@EXAMPLE.COM ").unwrap();
|
||||
assert_eq!(email.as_ref(), "user@example.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_email_no_at() {
|
||||
assert!(Email::new("userexample.com").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_email_no_domain() {
|
||||
assert!(Email::new("user@").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_email_no_local() {
|
||||
assert!(Email::new("@example.com").is_err());
|
||||
}
|
||||
}
|
||||
|
||||
mod password_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_valid_password() {
|
||||
assert!(Password::new("secret123").is_ok());
|
||||
assert!(Password::new("12345678").is_ok()); // Exactly 8 chars
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_password_too_short() {
|
||||
assert!(Password::new("1234567").is_err()); // 7 chars
|
||||
assert!(Password::new("").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_password_debug_hides_content() {
|
||||
let password = Password::new("supersecret").unwrap();
|
||||
let debug = format!("{:?}", password);
|
||||
assert!(!debug.contains("supersecret"));
|
||||
assert!(debug.contains("***"));
|
||||
}
|
||||
}
|
||||
}
|
||||
#[path = "tests/auth.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Controls who can view a channel's broadcast and stream.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum AccessMode {
|
||||
@@ -11,7 +10,6 @@ pub enum AccessMode {
|
||||
OwnerOnly,
|
||||
}
|
||||
|
||||
/// Position of the channel logo watermark overlay.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum LogoPosition {
|
||||
|
||||
@@ -40,17 +40,12 @@ macro_rules! uuid_id {
|
||||
};
|
||||
}
|
||||
|
||||
#[allow(unused_imports)]
|
||||
pub(crate) use uuid_id;
|
||||
|
||||
uuid_id!(UserId);
|
||||
uuid_id!(ChannelId);
|
||||
uuid_id!(SlotId);
|
||||
uuid_id!(BlockId);
|
||||
uuid_id!(ScheduleId);
|
||||
|
||||
/// Opaque media item identifier -- format is provider-specific.
|
||||
/// The domain never inspects the string; it just passes it back to the provider.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
|
||||
pub struct MediaItemId(String);
|
||||
|
||||
|
||||
@@ -4,14 +4,7 @@ use url::Url;
|
||||
|
||||
use super::auth::ValidationError;
|
||||
|
||||
// ============================================================================
|
||||
// OIDC Configuration Newtypes
|
||||
// ============================================================================
|
||||
|
||||
/// OIDC Issuer URL - validated URL for the identity provider
|
||||
///
|
||||
/// Stores the original string to preserve exact formatting (e.g., trailing slashes)
|
||||
/// since OIDC providers expect issuer URLs to match exactly.
|
||||
// Stores original string to preserve exact formatting — OIDC providers expect issuer URLs to match exactly
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(try_from = "String", into = "String")]
|
||||
pub struct IssuerUrl(String);
|
||||
@@ -19,7 +12,6 @@ pub struct IssuerUrl(String);
|
||||
impl IssuerUrl {
|
||||
pub fn new(value: impl AsRef<str>) -> Result<Self, ValidationError> {
|
||||
let value = value.as_ref().trim().to_string();
|
||||
// Validate URL format but store original string to preserve exact formatting
|
||||
Url::parse(&value).map_err(|e| ValidationError::InvalidUrl(e.to_string()))?;
|
||||
Ok(Self(value))
|
||||
}
|
||||
@@ -50,7 +42,6 @@ impl From<IssuerUrl> for String {
|
||||
}
|
||||
}
|
||||
|
||||
/// OIDC Client Identifier
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(try_from = "String", into = "String")]
|
||||
pub struct ClientId(String);
|
||||
@@ -90,7 +81,7 @@ impl From<ClientId> for String {
|
||||
}
|
||||
}
|
||||
|
||||
/// OIDC Client Secret - hidden in Debug output
|
||||
// Hidden in Debug for security
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub struct ClientSecret(String);
|
||||
|
||||
@@ -99,7 +90,6 @@ impl ClientSecret {
|
||||
Self(value.into())
|
||||
}
|
||||
|
||||
/// Check if the secret is empty (for public clients)
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.0.trim().is_empty()
|
||||
}
|
||||
@@ -111,6 +101,7 @@ impl AsRef<str> for ClientSecret {
|
||||
}
|
||||
}
|
||||
|
||||
// Hidden in Debug for security
|
||||
impl fmt::Debug for ClientSecret {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "ClientSecret(***)")
|
||||
@@ -130,9 +121,8 @@ impl<'de> Deserialize<'de> for ClientSecret {
|
||||
}
|
||||
}
|
||||
|
||||
// Note: ClientSecret should NOT implement Serialize
|
||||
// ClientSecret must NOT implement Serialize
|
||||
|
||||
/// OAuth Redirect URL - validated URL
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(try_from = "String", into = "String")]
|
||||
pub struct RedirectUrl(Url);
|
||||
@@ -174,7 +164,6 @@ impl From<RedirectUrl> for String {
|
||||
}
|
||||
}
|
||||
|
||||
/// OIDC Resource Identifier (optional audience)
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(try_from = "String", into = "String")]
|
||||
pub struct ResourceId(String);
|
||||
@@ -214,11 +203,6 @@ impl From<ResourceId> for String {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// OIDC Flow Newtypes (for type-safe session storage)
|
||||
// ============================================================================
|
||||
|
||||
/// CSRF Token for OIDC state parameter
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct CsrfToken(String);
|
||||
|
||||
@@ -240,7 +224,6 @@ impl fmt::Display for CsrfToken {
|
||||
}
|
||||
}
|
||||
|
||||
/// Nonce for OIDC ID token verification
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct OidcNonce(String);
|
||||
|
||||
@@ -262,7 +245,7 @@ impl fmt::Display for OidcNonce {
|
||||
}
|
||||
}
|
||||
|
||||
/// PKCE Code Verifier
|
||||
// Hidden in Debug for security
|
||||
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct PkceVerifier(String);
|
||||
|
||||
@@ -278,14 +261,14 @@ impl AsRef<str> for PkceVerifier {
|
||||
}
|
||||
}
|
||||
|
||||
// Hide PKCE verifier in Debug (security)
|
||||
// Hidden in Debug for security
|
||||
impl fmt::Debug for PkceVerifier {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "PkceVerifier(***)")
|
||||
}
|
||||
}
|
||||
|
||||
/// OAuth2 Authorization Code
|
||||
// Hidden in Debug for security
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub struct AuthorizationCode(String);
|
||||
|
||||
@@ -301,7 +284,7 @@ impl AsRef<str> for AuthorizationCode {
|
||||
}
|
||||
}
|
||||
|
||||
// Hide authorization code in Debug (security)
|
||||
// Hidden in Debug for security
|
||||
impl fmt::Debug for AuthorizationCode {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "AuthorizationCode(***)")
|
||||
@@ -315,24 +298,14 @@ impl<'de> Deserialize<'de> for AuthorizationCode {
|
||||
}
|
||||
}
|
||||
|
||||
/// Complete authorization URL data returned when starting OIDC flow
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AuthorizationUrlData {
|
||||
/// The URL to redirect the user to
|
||||
pub url: Url,
|
||||
/// CSRF token to store in session
|
||||
pub csrf_token: CsrfToken,
|
||||
/// Nonce to store in session
|
||||
pub nonce: OidcNonce,
|
||||
/// PKCE verifier to store in session
|
||||
pub pkce_verifier: PkceVerifier,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Configuration Newtypes
|
||||
// ============================================================================
|
||||
|
||||
/// JWT signing secret with minimum length requirement
|
||||
pub const MIN_JWT_SECRET_LENGTH: usize = 32;
|
||||
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
@@ -350,7 +323,6 @@ impl JwtSecret {
|
||||
Ok(Self(value))
|
||||
}
|
||||
|
||||
/// Create without validation (for development/testing)
|
||||
pub fn new_unchecked(value: impl Into<String>) -> Self {
|
||||
Self(value.into())
|
||||
}
|
||||
@@ -368,63 +340,6 @@ impl fmt::Debug for JwtSecret {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Tests
|
||||
// ============================================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
mod oidc_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_issuer_url_valid() {
|
||||
assert!(IssuerUrl::new("https://auth.example.com").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_issuer_url_invalid() {
|
||||
assert!(IssuerUrl::new("not-a-url").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_client_id_non_empty() {
|
||||
assert!(ClientId::new("my-client").is_ok());
|
||||
assert!(ClientId::new("").is_err());
|
||||
assert!(ClientId::new(" ").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_client_secret_hides_in_debug() {
|
||||
let secret = ClientSecret::new("super-secret");
|
||||
let debug = format!("{:?}", secret);
|
||||
assert!(!debug.contains("super-secret"));
|
||||
assert!(debug.contains("***"));
|
||||
}
|
||||
}
|
||||
|
||||
mod secret_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_jwt_secret_production_check() {
|
||||
let short = "short";
|
||||
let long = "a".repeat(32);
|
||||
|
||||
// Production mode enforces length
|
||||
assert!(JwtSecret::new(short, true).is_err());
|
||||
assert!(JwtSecret::new(&long, true).is_ok());
|
||||
|
||||
// Development mode allows short secrets
|
||||
assert!(JwtSecret::new(short, false).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_secrets_hide_in_debug() {
|
||||
let jwt = JwtSecret::new_unchecked("secret");
|
||||
assert!(!format!("{:?}", jwt).contains("secret"));
|
||||
}
|
||||
}
|
||||
}
|
||||
#[path = "tests/oidc.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// The broad category of a media item.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ContentType {
|
||||
@@ -9,75 +8,48 @@ pub enum ContentType {
|
||||
Short,
|
||||
}
|
||||
|
||||
/// Provider-agnostic filter for querying media items.
|
||||
///
|
||||
/// Each field is optional -- omitting it means "no constraint on this dimension".
|
||||
/// The `IMediaProvider` adapter interprets these fields in terms of its own API.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct MediaFilter {
|
||||
pub content_type: Option<ContentType>,
|
||||
pub genres: Vec<String>,
|
||||
/// Starting year of a decade: 1990 means 1990-1999.
|
||||
pub decade: Option<u16>,
|
||||
pub tags: Vec<String>,
|
||||
pub min_duration_secs: Option<u32>,
|
||||
pub max_duration_secs: Option<u32>,
|
||||
/// Abstract groupings interpreted by each provider (Jellyfin library, Plex section,
|
||||
/// filesystem path, etc.). An empty list means "all available content".
|
||||
pub collections: Vec<String>,
|
||||
/// Filter to one or more TV series by name. Use with `content_type: Episode`.
|
||||
/// With `Sequential` strategy each series plays in chronological order.
|
||||
/// Multiple series are OR-combined: any episode from any listed show is eligible.
|
||||
#[serde(default)]
|
||||
pub series_names: Vec<String>,
|
||||
/// Free-text search term. Intended for library browsing; typically omitted
|
||||
/// during schedule generation.
|
||||
pub search_term: Option<String>,
|
||||
}
|
||||
|
||||
/// How the scheduling engine fills a time block with selected media items.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum FillStrategy {
|
||||
/// Greedy bin-packing: at each step pick the longest item that still fits,
|
||||
/// minimising dead air. Good for variety blocks.
|
||||
BestFit,
|
||||
/// Pick items in the order returned by the provider -- ideal for series
|
||||
/// where episode sequence matters.
|
||||
Sequential,
|
||||
/// Shuffle the pool randomly then fill sequentially. Good for "shuffle play" channels.
|
||||
Random,
|
||||
}
|
||||
|
||||
/// Controls when previously aired items become eligible to play again.
|
||||
///
|
||||
/// An item is *on cooldown* if *either* threshold is met.
|
||||
/// `min_available_ratio` is a safety valve: if honouring the cooldown would
|
||||
/// leave fewer items than this fraction of the total pool, the cooldown is
|
||||
/// ignored and all items become eligible. This prevents small libraries from
|
||||
/// running completely dry.
|
||||
const DEFAULT_COOLDOWN_DAYS: u32 = 30;
|
||||
const DEFAULT_MIN_AVAILABLE_RATIO: f32 = 0.2;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RecyclePolicy {
|
||||
/// Do not replay an item within this many calendar days.
|
||||
pub cooldown_days: Option<u32>,
|
||||
/// Do not replay an item within this many schedule generations.
|
||||
pub cooldown_generations: Option<u32>,
|
||||
/// Always keep at least this fraction (0.0-1.0) of the matching pool
|
||||
/// available for selection, even if their cooldown has not yet expired.
|
||||
pub min_available_ratio: f32,
|
||||
}
|
||||
|
||||
impl Default for RecyclePolicy {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
cooldown_days: Some(30),
|
||||
cooldown_days: Some(DEFAULT_COOLDOWN_DAYS),
|
||||
cooldown_generations: None,
|
||||
min_available_ratio: 0.2,
|
||||
min_available_ratio: DEFAULT_MIN_AVAILABLE_RATIO,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Day of week, used as key in weekly schedule configs.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Weekday {
|
||||
@@ -106,8 +78,7 @@ impl From<chrono::Weekday> for Weekday {
|
||||
|
||||
impl Weekday {
|
||||
pub fn all() -> [Weekday; 7] {
|
||||
// ISO week order: Monday = index 0, Sunday = index 6.
|
||||
// The schedule engine depends on this order when iterating days.
|
||||
// ISO week order: Monday first. Schedule engine depends on this ordering.
|
||||
[
|
||||
Weekday::Monday,
|
||||
Weekday::Tuesday,
|
||||
@@ -121,24 +92,5 @@ impl Weekday {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn from_chrono_weekday_all_variants() {
|
||||
assert_eq!(Weekday::from(chrono::Weekday::Mon), Weekday::Monday);
|
||||
assert_eq!(Weekday::from(chrono::Weekday::Tue), Weekday::Tuesday);
|
||||
assert_eq!(Weekday::from(chrono::Weekday::Wed), Weekday::Wednesday);
|
||||
assert_eq!(Weekday::from(chrono::Weekday::Thu), Weekday::Thursday);
|
||||
assert_eq!(Weekday::from(chrono::Weekday::Fri), Weekday::Friday);
|
||||
assert_eq!(Weekday::from(chrono::Weekday::Sat), Weekday::Saturday);
|
||||
assert_eq!(Weekday::from(chrono::Weekday::Sun), Weekday::Sunday);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_returns_monday_first_sunday_last() {
|
||||
let days = Weekday::all();
|
||||
assert_eq!(days[0], Weekday::Monday);
|
||||
assert_eq!(days[6], Weekday::Sunday);
|
||||
}
|
||||
}
|
||||
#[path = "tests/scheduling.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
use crate::value_objects::ContentType;
|
||||
|
||||
/// Filter for searching the local library.
|
||||
///
|
||||
/// Uses private fields with builder methods and getters to enforce
|
||||
/// encapsulation and allow future validation.
|
||||
const DEFAULT_SEARCH_LIMIT: u32 = 50;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LibrarySearchFilter {
|
||||
provider_id: Option<String>,
|
||||
@@ -25,7 +23,6 @@ impl LibrarySearchFilter {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
// Builder methods
|
||||
pub fn with_provider_id(mut self, id: impl Into<String>) -> Self {
|
||||
self.provider_id = Some(id.into());
|
||||
self
|
||||
@@ -75,7 +72,6 @@ impl LibrarySearchFilter {
|
||||
self
|
||||
}
|
||||
|
||||
// Getters
|
||||
pub fn provider_id(&self) -> Option<&str> {
|
||||
self.provider_id.as_deref()
|
||||
}
|
||||
@@ -128,37 +124,11 @@ impl Default for LibrarySearchFilter {
|
||||
search_term: None,
|
||||
season_number: None,
|
||||
offset: 0,
|
||||
limit: 50,
|
||||
limit: DEFAULT_SEARCH_LIMIT,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn default_limit_is_50() {
|
||||
let f = LibrarySearchFilter::default();
|
||||
assert_eq!(f.limit(), 50);
|
||||
assert_eq!(f.offset(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builder_chain() {
|
||||
let f = LibrarySearchFilter::new()
|
||||
.with_provider_id("jellyfin")
|
||||
.with_content_type(ContentType::Movie)
|
||||
.with_genres(vec!["Action".into()])
|
||||
.with_decade(1990)
|
||||
.with_limit(25)
|
||||
.with_offset(10);
|
||||
|
||||
assert_eq!(f.provider_id(), Some("jellyfin"));
|
||||
assert_eq!(f.content_type(), Some(&ContentType::Movie));
|
||||
assert_eq!(f.genres(), &["Action".to_string()]);
|
||||
assert_eq!(f.decade(), Some(1990));
|
||||
assert_eq!(f.limit(), 25);
|
||||
assert_eq!(f.offset(), 10);
|
||||
}
|
||||
}
|
||||
#[path = "tests/search.rs"]
|
||||
mod tests;
|
||||
|
||||
57
crates/domain/src/value_objects/tests/auth.rs
Normal file
57
crates/domain/src/value_objects/tests/auth.rs
Normal file
@@ -0,0 +1,57 @@
|
||||
use super::*;
|
||||
|
||||
mod email_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_valid_email() {
|
||||
assert!(Email::new("user@example.com").is_ok());
|
||||
assert!(Email::new("USER@EXAMPLE.COM").is_ok());
|
||||
assert!(Email::new(" user@example.com ").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_email_normalizes() {
|
||||
let email = Email::new(" USER@EXAMPLE.COM ").unwrap();
|
||||
assert_eq!(email.as_ref(), "user@example.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_email_no_at() {
|
||||
assert!(Email::new("userexample.com").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_email_no_domain() {
|
||||
assert!(Email::new("user@").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_email_no_local() {
|
||||
assert!(Email::new("@example.com").is_err());
|
||||
}
|
||||
}
|
||||
|
||||
mod password_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_valid_password() {
|
||||
assert!(Password::new("secret123").is_ok());
|
||||
assert!(Password::new("12345678").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_password_too_short() {
|
||||
assert!(Password::new("1234567").is_err());
|
||||
assert!(Password::new("").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_password_debug_hides_content() {
|
||||
let password = Password::new("supersecret").unwrap();
|
||||
let debug = format!("{:?}", password);
|
||||
assert!(!debug.contains("supersecret"));
|
||||
assert!(debug.contains("***"));
|
||||
}
|
||||
}
|
||||
51
crates/domain/src/value_objects/tests/oidc.rs
Normal file
51
crates/domain/src/value_objects/tests/oidc.rs
Normal file
@@ -0,0 +1,51 @@
|
||||
use super::*;
|
||||
|
||||
mod oidc_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_issuer_url_valid() {
|
||||
assert!(IssuerUrl::new("https://auth.example.com").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_issuer_url_invalid() {
|
||||
assert!(IssuerUrl::new("not-a-url").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_client_id_non_empty() {
|
||||
assert!(ClientId::new("my-client").is_ok());
|
||||
assert!(ClientId::new("").is_err());
|
||||
assert!(ClientId::new(" ").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_client_secret_hides_in_debug() {
|
||||
let secret = ClientSecret::new("super-secret");
|
||||
let debug = format!("{:?}", secret);
|
||||
assert!(!debug.contains("super-secret"));
|
||||
assert!(debug.contains("***"));
|
||||
}
|
||||
}
|
||||
|
||||
mod secret_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_jwt_secret_production_check() {
|
||||
let short = "short";
|
||||
let long = "a".repeat(32);
|
||||
|
||||
assert!(JwtSecret::new(short, true).is_err());
|
||||
assert!(JwtSecret::new(&long, true).is_ok());
|
||||
|
||||
assert!(JwtSecret::new(short, false).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_secrets_hide_in_debug() {
|
||||
let jwt = JwtSecret::new_unchecked("secret");
|
||||
assert!(!format!("{:?}", jwt).contains("secret"));
|
||||
}
|
||||
}
|
||||
19
crates/domain/src/value_objects/tests/scheduling.rs
Normal file
19
crates/domain/src/value_objects/tests/scheduling.rs
Normal file
@@ -0,0 +1,19 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn from_chrono_weekday_all_variants() {
|
||||
assert_eq!(Weekday::from(chrono::Weekday::Mon), Weekday::Monday);
|
||||
assert_eq!(Weekday::from(chrono::Weekday::Tue), Weekday::Tuesday);
|
||||
assert_eq!(Weekday::from(chrono::Weekday::Wed), Weekday::Wednesday);
|
||||
assert_eq!(Weekday::from(chrono::Weekday::Thu), Weekday::Thursday);
|
||||
assert_eq!(Weekday::from(chrono::Weekday::Fri), Weekday::Friday);
|
||||
assert_eq!(Weekday::from(chrono::Weekday::Sat), Weekday::Saturday);
|
||||
assert_eq!(Weekday::from(chrono::Weekday::Sun), Weekday::Sunday);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_returns_monday_first_sunday_last() {
|
||||
let days = Weekday::all();
|
||||
assert_eq!(days[0], Weekday::Monday);
|
||||
assert_eq!(days[6], Weekday::Sunday);
|
||||
}
|
||||
26
crates/domain/src/value_objects/tests/search.rs
Normal file
26
crates/domain/src/value_objects/tests/search.rs
Normal file
@@ -0,0 +1,26 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn default_limit_is_50() {
|
||||
let f = LibrarySearchFilter::default();
|
||||
assert_eq!(f.limit(), 50);
|
||||
assert_eq!(f.offset(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builder_chain() {
|
||||
let f = LibrarySearchFilter::new()
|
||||
.with_provider_id("jellyfin")
|
||||
.with_content_type(ContentType::Movie)
|
||||
.with_genres(vec!["Action".into()])
|
||||
.with_decade(1990)
|
||||
.with_limit(25)
|
||||
.with_offset(10);
|
||||
|
||||
assert_eq!(f.provider_id(), Some("jellyfin"));
|
||||
assert_eq!(f.content_type(), Some(&ContentType::Movie));
|
||||
assert_eq!(f.genres(), &["Action".to_string()]);
|
||||
assert_eq!(f.decade(), Some(1990));
|
||||
assert_eq!(f.limit(), 25);
|
||||
assert_eq!(f.offset(), 10);
|
||||
}
|
||||
Reference in New Issue
Block a user