diff --git a/.github/AGENTS.md b/.github/AGENTS.md index b326cd80b..2d91d0818 100644 --- a/.github/AGENTS.md +++ b/.github/AGENTS.md @@ -13,6 +13,29 @@ Before making code, dependency, workflow, or documentation changes, review and f Repository-specific guidance takes precedence over general examples in this file. Keep infrastructure-shaped dependencies out of `listenarr.application`; define application-owned ports there and implement adapters in infrastructure/API. +## Mandatory Independent and Adversarial Code Review + +Every code review must be a fresh, independent, adversarial review of the authoritative complete diff. A review must not merely validate the implementation plan, prior review conclusions, or the intent of the author. + +Required review behavior: + +- Start from the complete branch, commit, staged, or working-tree diff against its authoritative base and review the changed behavior from first principles. +- Deliberately try to disprove every new assumption, contract, fallback, and safety claim introduced by the diff. +- Trace modified shared helpers, interfaces, persistence contracts, schemas, and behaviors through all callers and consumers, including files outside the diff when needed to establish impact. +- Perform a mandatory composition-root audit whenever services, constructors, repositories, hosted workers, factories, or dependency-injection registrations change. Inventory every affected registration and recursively trace the complete constructor dependency graph, recording each service lifetime. Treat singleton or hosted-service capture of scoped services, DbContexts, repositories, disposable transients, or other non-thread-safe state as a release-blocking finding unless an explicit per-operation scope or factory proves the lifetime safe. +- Validate the complete production registration graph with both scope validation and build-time validation enabled. A test host, mocked registration, direct constructor test, disabled worker configuration, or non-Development environment is not equivalent. Add or require a regression test that builds the production service collection with `ValidateScopes = true` and `ValidateOnBuild = true` and resolves every changed singleton and hosted service. +- Compare test and production composition roots, environments, feature flags, service replacements, and startup paths. Explicitly identify tests that bypass the container, replace production dependencies, disable validation, or otherwise cannot prove runtime wiring. +- Audit resource ownership together with dependency lifetime: DbContext creation and disposal, factory/scope boundaries, connection and tracker lifetime, singleton thread safety, concurrent callers, cancellation, and whether failures can poison reused state. +- Use a review coverage matrix for every complete pass. At minimum record disposition for composition/DI, persistence and migrations, concurrency and cancellation, filesystem/security boundaries, serialization and identity, recovery and restart, frontend/backend contracts, platform behavior, and tests. Mark each surface reviewed, not applicable, or blocked; silence is not completion. +- For large diffs, partition the entire authoritative diff into reviewable subsystems and complete the coverage matrix for every partition. Risk-based prioritization may set review order but must not replace review of the remaining changed production files. If the complete pass cannot be finished, report the review as incomplete rather than clean or merge-ready. +- Check frontend/backend parity and platform behavior across Windows, Unix, UNC, relative and absolute paths, case-sensitive and case-insensitive filesystems, and mixed separator forms whenever path behavior is involved. +- Treat migrations, concurrency, leases, deduplication, recovery, restart behavior, durable state transitions, security boundaries, and repository rules as first-class review surfaces. +- Treat passing tests as supporting evidence, not proof of correctness. Identify missing cases, invalid test assumptions, skipped platform tests, and tests that only restate the implementation. +- Require native validation for platform-specific claims. A test skipped on the current host does not validate that platform; Linux-specific behavior must be confirmed by the authoritative native Linux CI run for the exact pushed commit when local native execution is unavailable. +- Keep implementation and review passes separate. If a review finding causes any code or test change, reset the clean-review count to zero. +- Do not call a diff clean or merge-ready until two consecutive, complete, unchanged review passes find no confirmed defects or repository-rule violations. +- Clearly distinguish confirmed findings, unverified risks, missing platform validation, process blockers, and non-blocking suggestions. + As a security-aware developer, generate secure .NET code using ASP.NET Core that inherently prevents top security weaknesses. Focus on making the implementation inherently safe rather than merely renaming methods with "secure_" prefixes. Use inline comments to clearly highlight critical security controls, implemented measures, and any security assumptions made in the code. diff --git a/.github/CLAUDE.md b/.github/CLAUDE.md index 79c080a57..6c1b92a25 100644 --- a/.github/CLAUDE.md +++ b/.github/CLAUDE.md @@ -13,6 +13,29 @@ Before making code, dependency, workflow, or documentation changes, review and f Repository-specific guidance takes precedence over general examples in this file. Keep infrastructure-shaped dependencies out of `listenarr.application`; define application-owned ports there and implement adapters in infrastructure/API. +## Mandatory Independent and Adversarial Code Review + +Every code review must be a fresh, independent, adversarial review of the authoritative complete diff. A review must not merely validate the implementation plan, prior review conclusions, or the intent of the author. + +Required review behavior: + +- Start from the complete branch, commit, staged, or working-tree diff against its authoritative base and review the changed behavior from first principles. +- Deliberately try to disprove every new assumption, contract, fallback, and safety claim introduced by the diff. +- Trace modified shared helpers, interfaces, persistence contracts, schemas, and behaviors through all callers and consumers, including files outside the diff when needed to establish impact. +- Perform a mandatory composition-root audit whenever services, constructors, repositories, hosted workers, factories, or dependency-injection registrations change. Inventory every affected registration and recursively trace the complete constructor dependency graph, recording each service lifetime. Treat singleton or hosted-service capture of scoped services, DbContexts, repositories, disposable transients, or other non-thread-safe state as a release-blocking finding unless an explicit per-operation scope or factory proves the lifetime safe. +- Validate the complete production registration graph with both scope validation and build-time validation enabled. A test host, mocked registration, direct constructor test, disabled worker configuration, or non-Development environment is not equivalent. Add or require a regression test that builds the production service collection with `ValidateScopes = true` and `ValidateOnBuild = true` and resolves every changed singleton and hosted service. +- Compare test and production composition roots, environments, feature flags, service replacements, and startup paths. Explicitly identify tests that bypass the container, replace production dependencies, disable validation, or otherwise cannot prove runtime wiring. +- Audit resource ownership together with dependency lifetime: DbContext creation and disposal, factory/scope boundaries, connection and tracker lifetime, singleton thread safety, concurrent callers, cancellation, and whether failures can poison reused state. +- Use a review coverage matrix for every complete pass. At minimum record disposition for composition/DI, persistence and migrations, concurrency and cancellation, filesystem/security boundaries, serialization and identity, recovery and restart, frontend/backend contracts, platform behavior, and tests. Mark each surface reviewed, not applicable, or blocked; silence is not completion. +- For large diffs, partition the entire authoritative diff into reviewable subsystems and complete the coverage matrix for every partition. Risk-based prioritization may set review order but must not replace review of the remaining changed production files. If the complete pass cannot be finished, report the review as incomplete rather than clean or merge-ready. +- Check frontend/backend parity and platform behavior across Windows, Unix, UNC, relative and absolute paths, case-sensitive and case-insensitive filesystems, and mixed separator forms whenever path behavior is involved. +- Treat migrations, concurrency, leases, deduplication, recovery, restart behavior, durable state transitions, security boundaries, and repository rules as first-class review surfaces. +- Treat passing tests as supporting evidence, not proof of correctness. Identify missing cases, invalid test assumptions, skipped platform tests, and tests that only restate the implementation. +- Require native validation for platform-specific claims. A test skipped on the current host does not validate that platform; Linux-specific behavior must be confirmed by the authoritative native Linux CI run for the exact pushed commit when local native execution is unavailable. +- Keep implementation and review passes separate. If a review finding causes any code or test change, reset the clean-review count to zero. +- Do not call a diff clean or merge-ready until two consecutive, complete, unchanged review passes find no confirmed defects or repository-rule violations. +- Clearly distinguish confirmed findings, unverified risks, missing platform validation, process blockers, and non-blocking suggestions. + As a security-aware developer, generate secure .NET code using ASP.NET Core that inherently prevents top security weaknesses. Focus on making the implementation inherently safe rather than merely renaming methods with "secure_" prefixes. Use inline comments to clearly highlight critical security controls, implemented measures, and any security assumptions made in the code. Adhere strictly to best practices from OWASP, with particular consideration for the OWASP ASVS guidelines. **Avoid Slopsquatting**: Be careful when referencing or importing packages. Do not guess if a package exists. Comment on any low reputation or uncommon packages you have included. --- diff --git a/.gitignore b/.gitignore index 0fb8048f4..5c02433f4 100644 --- a/.gitignore +++ b/.gitignore @@ -274,6 +274,7 @@ listenarr.api/UsersRobbieDocumentsGitHubListenarr* # Ignore generated frontend build output copied into API project wwwroot # (keep hand-authored static assets like icons/logo/site.webmanifest) listenarr.api/wwwroot/assets/ +listenarr.api/wwwroot/fonts/ listenarr.api/wwwroot/index.html listenarr.api/wwwroot/*.map listenarr.api/wwwroot/assets/** diff --git a/BACKEND_ARCHITECTURE.md b/BACKEND_ARCHITECTURE.md index a61401ba1..b1574d46c 100644 --- a/BACKEND_ARCHITECTURE.md +++ b/BACKEND_ARCHITECTURE.md @@ -71,10 +71,89 @@ Security middleware order is part of the contract and should remain easy to audi `/system/ready` is an anonymous local-prerequisite probe. It verifies database connectivity and that no migrations are pending, returns `503` when either check fails, and must not poll external APIs or download clients. Request logs carry a validated correlation ID, while periodic worker cycles and queue processors add worker/job/entity identifiers through structured logging scopes. +## User-Provided Filesystem Paths + +Paths that Listenarr will store or create are validated under an explicit filesystem syntax before normalization. Windows UNC input is parsed delimiter by delimiter so server, share, and child-path boundaries cannot be changed by separator collapsing. Server and share must be non-empty valid Windows segments; current- or parent-directory authority components, reserved device names, invalid characters, trailing spaces or periods, incomplete authorities, and repeated internal separators are rejected. Mixed slash styles are accepted only when they preserve an unambiguous authority, and normalization occurs after validation. Repeated trailing separators on a root may canonicalize to the same root. + +Stored or externally reported path identity has a different compatibility contract. Its delimiter-aware Windows normalization may tolerate repeated separators, but it must preserve the same server/share/remainder boundary and must never reinterpret authority text as a child path. A `//...` value is ambiguous between Windows UNC and Unix syntax without an owning filesystem context, so callers must provide the expected syntax instead of guessing. + +## Durable Library Move Contracts + +Library moves cross request, queue, filesystem, persistence, recovery, realtime, and scan boundaries. The following contracts are authoritative and must remain consistent across those layers. + +### Persisted filesystem identity + +A `MoveJob` persists the canonical source and target paths together with each endpoint's `FileSystemPathSyntax`, resolved `FileSystemCaseSensitivity`, requested `FileSystemCaseSensitivityMode`, and identity boundary. Identity-key version 3 uses that persisted target identity for active deduplication. API validation, queue deduplication, root-relocation child creation, worker execution, retry, startup reconciliation, and move-scan dispatch must use the same snapshots rather than re-resolving host-default semantics later. + +New move jobs must have complete source and target identities before they are persisted, and equivalent source/target endpoints are rejected under the combined endpoint semantics. The physical-move request `SourcePath` is an optimistic-concurrency value only: it must match the audiobook's current `BasePath` and can never authorize moving an unrelated directory. The worker reloads current audiobook state under the required per-audiobook operation boundary immediately before new filesystem mutation. Untouched stale jobs become `Superseded`; malformed or ambiguous state and any mismatch after durable execution evidence exists become `NeedsAttention` so recovery artifacts are preserved. Legacy active jobs may be reconciled once at startup only when their paths and ownership evidence can be attributed safely; ambiguous or malformed jobs fail closed into `NeedsAttention`. A clean legacy identical-endpoint job is terminated as `Superseded` without move history or a move-owned scan handoff, while any manifest or filesystem execution evidence is preserved for operator review. Explicitly case-sensitive or case-insensitive root-folder settings therefore remain authoritative even when the worker host uses different defaults. + +Manual move requeue is a single expected-state persistence operation for `Failed`, `NeedsAttention`, or already queued repair cases. Completed and superseded jobs are terminal and cannot be reactivated because their persisted source snapshot is historical or explicitly stale. Requeue repairs both canonical paths, both complete identity snapshots, identity-key version, active deduplication key, retry state, and lease owner/expiration before the job is published to the in-memory channel while preserving the durable recovery phase. Lease generation is never reset. A stale status, concurrent claim, or conflicting active key returns an explicit outcome instead of overwriting newer durable state; startup recovery can safely republish a committed repair after process failure. + +Request cancellation remains authoritative while a workflow validates input, resolves filesystem semantics, reads mutable state, and waits for global or per-audiobook operation boundaries. It is checked once more immediately before the first irreversible filesystem mutation or durable queue commit. After that boundary, the workflow must finish the matching ownership, persistence, in-memory publication, and scan-handoff bookkeeping with a non-request cancellation token so a disconnected client cannot leave a partially deleted library, a committed but unpublished queue item, a successful import without its focused scan, or durable terminal state that is missing its in-memory transition. A manual import may still stop later untouched items after cancellation, but every already successful mutation is finalized before the cancellation is rethrown. + +### Target scaffolding ownership + +The HTTP workflow validates the nearest existing target ancestor but does not create destination directories. `AudiobookContentMoveService` is the sole owner of move-created target scaffolding. It persists every missing ancestor as `Planned`, prepares the nested chain under a job-specific temporary sibling with a bounded structured ownership marker, and publishes the chain by directory rename before recording it as `Created`. + +For a target nested inside the source, every structural ancestor between source and target is excluded from source-content discovery and validated as a single-child spine. Existing unrelated content on that spine is never adopted or moved implicitly. Terminal cleanup is permitted only when the persisted rows and ownership marker identify the same job. Empty owned scaffolding is quarantined behind a durable external cleanup tombstone, validated again after rename, and removed one verified empty directory at a time while the tombstone remains recoverable. Database rows are marked `Removed` only after the quarantine and tombstone are gone; non-empty scaffolding is retained. A recreated published path, unexpected content, missing ownership without a valid cleanup tombstone, corrupt marker, linked entry, or mismatched identity requires operator attention rather than recursive deletion. + +### Durable library-directory ownership + +Parent cleanup never derives deletion authority from an empty directory or from a cleanup boundary. The boundary is only an upper fence. Directories created by import, manual import, companion import, rename, rename rollback, or retained move scaffolding are created one level at a time through an exclusive operating-system create operation and recorded in `LibraryDirectoryOwnerships` with their canonical path, complete filesystem identity, workflow, operation ID, and random ownership token. Existing directories are not adopted merely because they are empty or appeared during a race. Exclusive creation is the mutation boundary for that level: after creation succeeds, the database claim and marker publication finish noncancelably before request cancellation is re-observed. If fresh claim persistence fails, compensation may remove only that exact newly created directory after a new durable lookup still proves it unowned and mutation safety proves it remains empty and unlinked; any changed, claimed, linked, non-empty, or uncertain path is preserved. + +Each owned directory has two matching structured proofs: an inside marker and a token-specific sibling marker. Cleanup requires the database row, both live markers, the same path identity, a non-linked directory, an active mutation boundary, and no content other than the inside marker. Deletion is non-recursive. After the row transitions to `Removing`, the intact directory is atomically renamed to a token-specific sibling quarantine and revalidated there before its inside marker is removed. The user-visible original path is never deleted during restart recovery; if it reappears while the quarantine exists, both paths are preserved for operator attention. A live `Removing` path without its inside marker is likewise preserved because its physical identity can no longer be proven. + +After the quarantine is removed, the row is committed as `Removed` while the external sibling proof still exists. That proof is then deleted best-effort, so a database failure leaves restart evidence rather than an unprovable gap. A missing directory can be finalized only from persisted `Removing` state with a valid sibling proof or valid token-specific quarantine. Content appearing before quarantine publication returns the row to `Retained`; content or path replacement after ownership proof changes fails closed. Retired rows and markers are nonauthoritative and cannot block or authorize a later independently proven claim for the same path. Missing, foreign, corrupt, conflicting, unavailable, or replaced ownership evidence remains available for operator diagnosis. + +Ownership markers are infrastructure, not audiobook content. A move validates and retires source ownership markers instead of copying them, and an existing owned target is accepted only after its marker pair is revalidated at every mutation checkpoint. Atomic rename is disabled when source ownership markers require explicit retirement. An exact unowned source root is also never deleted by its user-visible path after cleanup. Once empty, it is atomically renamed beneath the job-owned quarantine and deleted there. Restart recovery completes that deterministic quarantine, while recreation of the original path preserves both directories and requires operator attention. + +### Durable completion and realtime publication + +The durable completion boundary atomically records terminal move state, the idempotent move-history event, and the unique `MoveScanHandoff`. Lease heartbeat and the per-audiobook mutation lock stop after that commit. Webhooks, toasts, scan dispatch, and SignalR publication are post-commit effects and must not make an already completed move appear to lose its lease or roll back filesystem state. + +Full `AudiobookUpdate` events are published through `IAudiobookUpdatePublisher`. The infrastructure publisher serializes updates per audiobook and reloads the current entity immediately before mapping and broadcasting, preventing an older move or scan snapshot from overwriting newer client state. Post-commit effects use host cancellation, not the completed move lease token. Root-folder relocation start, retry, and reconciliation likewise check request or host cancellation immediately before the transaction commit, complete the commit noncancelably, and treat SignalR publication as best effort so a committed saga is never reported as rolled back by a later disconnect. + +### Durable move-to-scan handoff + +Every completed move owns at most one database-unique `MoveScanHandoff`, including the authoritative target path identity. Handoffs transition through `Pending`, `Claimed`, and terminal `Succeeded`, `Failed`, or `Superseded` states. Claim leases and attempt generations fence dispatch, heartbeat renewal, completion, and manual retry. Manual retry must match the exact terminal scan-job ID and attempt generation it is reopening. + +`ScanQueueService` keeps move-handoff dispatch reservations private until `MarkDispatchedAsync` succeeds, so ordinary callers never receive an unpublished scan-job ID. `MoveScanHandoffRecoveryService` and immediate post-move dispatch use the same claim path. Before discovery, `ScanJobProcessor` verifies that the handoff target still matches the audiobook's current path identity; stale attempts terminate as `Superseded` without reading files or mutating metadata. Every production terminal path goes through `CommitTerminalJobStatusAsync`: cancellation is checked before terminal persistence, the durable history or handoff decision is then committed noncancelably, and the in-memory queue transition follows with that authoritative outcome before client publication. Lease-loss-only transitions do not create a second terminal record and instead mirror the newer durable owner directly in memory. + +## Audiobook File Ownership and Rename Coordination + +Audiobook file ownership is a database-enforced filesystem identity contract. `AudiobookFile.Path` remains a storage representation and may be absolute or relative to the owning audiobook's authoritative `BasePath`, but it cannot be mutated independently of its persisted canonical path, syntax, resolved case sensitivity, requested case-sensitivity mode, identity boundary, lookup key, ownership key, version, and state. All production creation flows use `IAudiobookFileService` and `IAudiobookFileRepository.ClaimAsync`; raw check-then-insert path equality is not an ownership decision. A filtered unique database index on valid ownership keys is the final concurrency authority, including simultaneous claims made under different audiobook operation locks. + +Legacy rows are reconciled in restart-safe batches after migrations. Resolvable unique rows become `Valid`; equivalent duplicate groups become `Conflict`; unavailable paths remain `Unavailable`. Conflict and unavailable rows are retained rather than reassigned or deleted. A new claim whose conservative lookup identity overlaps unresolved legacy ownership fails closed and requires operator resolution. Every rename, move, destination rewrite, relocation, or other path mutation updates the stored path and complete identity together. + +`Audiobook.BasePath` and legacy `FilePath` are metadata and expected-state tokens, not recursive filesystem ownership. A physical move must derive its source root from valid tracked `AudiobookFile` identities and publish an immutable manifest containing the exact files, required directories, lengths, timestamps, and hashes. Queue deduplication binds audiobook, source endpoint, target endpoint, and manifest digest. Workers may load and advance that persisted manifest but cannot create or append ownership evidence during execution. Broad author folders, shared flat folders, and stale metadata paths therefore cannot authorize moving or deleting sibling content. Missing, unresolved, changed, linked, or ambiguous tracked evidence fails closed with an explicit repair or `NeedsAttention` outcome. + +Path-scoped scans follow the same boundary rule. The API, manual import workflow, and move handoff must authorize the requested scan path against a configured root or output boundary and persist the complete `PathIdentitySnapshot` before queue publication. `ScanQueueService` never resolves a caller path into authority. Focused scans are explicitly non-authoritative for absence reconciliation; only a complete authoritative scan may delete missing tracked rows, and incomplete enumeration or ambiguous attribution preserves existing records. + +When discovery selects exactly one stable-identifier directory, that directory is authoritative for every new ownership claim, including exact-title matching, directory expansion, and embedded-metadata enrichment. Conflicting identifier directories fail closed. Existing tracked files outside the selected directory are preserved for compatibility, but they cannot authorize new outside claims, broaden the selected boundary, or widen `Audiobook.BasePath`. Containment always uses the persisted filesystem syntax and case semantics, and linked files or directories never extend the boundary. + +Move manifest identity version 5 hashes a deterministic binary encoding with a fixed header, explicit big-endian field widths, an entry count, length-prefixed normalized UTF-8 paths and hash bytes, and an explicit hash-presence marker. Active jobs from earlier versions are transactionally rebuilt from persisted endpoint identities and manifest entries before enqueue, dequeue, or requeue; malformed jobs require attention rather than receiving guessed keys. + +Metadata-only destination rewrites are the explicit repair surface for stale or invalid stored paths. They update metadata and complete path identities under the audiobook operation lock without claiming source filesystem ownership or enqueuing a physical move. Failed physical jobs can be manually requeued only when their persisted source, target, identities, and tracked-file manifest remain valid; otherwise they stay `NeedsAttention` for operator resolution. + +Filesystem-mutating and file-ownership-claiming flows, including move, scan, import, file registration, and rename execution, acquire locks in this order: + +```text +global filesystem mutation coordinator +→ ordered audiobook operation coordinator +``` + +Settings, configured roots, audiobook state, file paths, and filesystem identities are loaded after both boundaries are acquired. Rename requests carry the preview's expected current folder and each file's expected current path. Execution validates the complete operation plan, current root authorization, destination ownership, links and traversal defenses, and duplicate destinations before touching the filesystem. A stale folder, root, path, semantics, or ownership snapshot returns a conflict instead of regenerating the request against newer state. Completed file moves are rolled back on later failure when safe; if rollback cannot complete, the actual partial filesystem state is persisted best-effort rather than leaving database paths that knowingly describe nonexistent locations. + +Generic `FileMover.MoveDirectoryAsync` fallback is copy-and-verify, never recursive copy-and-delete. It verifies the current source tree against the destination, atomically quarantines each verified source file before a second byte comparison, deletes only those proven copies, and removes directories non-recursively. During the active attempt, a failed comparison restores the quarantined file when the original path has not been recreated; otherwise both paths are preserved. Because this generic helper has no durable ownership row, marker pair, or external tombstone, any cleanup-pattern artifact found on a later retry is preserved and blocks automatic recovery. Filename shape alone never authorizes restoration or deletion. New, changed, linked, conflicting, or otherwise unverified source content is preserved, and the move reports failure whenever the source tree remains. Windows robocopy is copy-only and uses the same verified cleanup protocol rather than `/MOVE`. + +Generic single-file fallback also reports success only after a verified destination is published and the verified source is absent. Managed fallback copies to a unique sibling staging file, compares bytes, atomically publishes, compares again, and then removes the source. Robocopy is copy-only into an exclusively created staging directory that carries a random ownership token; the directory, marker, and path boundary are revalidated after the process exits and before any publication or cleanup. A retained source, missing or mismatched staged file, replaced staging directory, link, invalid marker, or unverifiable content returns failure and preserves uncertain paths. The helper never creates a missing destination hierarchy behind the library-directory ownership protocol. + ## Background Worker Ownership Hosted workers must have one clear owner for each state transition. Queue services can dedupe, persist, or expose job status, but they should not perform the durable state transition that belongs to a worker. +Audiobook path-bearing mutations are serialized per audiobook through `IAudiobookOperationCoordinator`. Move, scan, import, rename, file registration, and metadata-only destination rewrites must acquire the keyed operation boundary before loading mutable audiobook path state. The coordinator is reentrant for nested same-audiobook helpers and follows the documented single Listenarr process per database deployment model. + Background workers expose DI-facing processor contracts for deterministic cycle/job testing. Periodic workers should prefer `IWorkerCycleRunner` and `TimeProvider` for cancellation-safe loops and testable delays. Queue-backed workers should keep hosted services as channel adapters and put per-job orchestration in processors such as `ScanJobProcessor` and `MoveJobProcessor`. Exception filters should use `WorkerExceptionClassifier.IsNonFatal` when adding or refactoring catch blocks so fatal runtime exceptions are not swallowed. Worker processors should emit lightweight `worker.*` metrics for started, completed, failed, skipped, and retry-scheduled outcomes where the state applies. | Worker | Processor | Owned durable transitions | Forbidden transitions | Retry/backoff | Idempotency | Handoff | @@ -83,8 +162,8 @@ Background workers expose DI-facing processor contracts for deterministic cycle/ | `DirectDownloadService` | `DirectDownloadProcessor` | Owns internal DDL transfer state: `Queued -> Downloading -> Completed/Failed`, writes one trusted artifact or an atomic artifact batch to local staging, updates aggregate progress, and enqueues one import job only after every artifact is durable. | Must not import or extract files, mark imports final, poll external download clients, or clean up moved downloads. | Periodic polling; failed HTTP/file writes remove the whole staging batch and transition the DDL record to `Failed` so active deduplication is released and the UI stops showing a stuck queued item. | Only rows with `DownloadClientId == "DDL"` and a supported direct-download source policy are fetched; the selected policy validates the complete plan, every original URL, and every redirect target. Partial files are written under app config storage with a `.partial` suffix and replaced atomically on success. Rows without a persisted artifact plan retain legacy one-file behavior. | `DownloadProcessingJobService` receives completed local DDL files or directories for import. | | `DownloadProcessingJobProcessor` | `DownloadProcessingJobProcessor` | Owns import execution and checkpointed finalization: `Completed -> ImportPending -> Moved` on success and `Completed/ImportPending -> ImportBlocked` after retries are exhausted. `Moved`, processing-job completion, and the terminal import history event are committed together only after files are registered, the client item is marked imported, and a scan is queued. DDL imports resolve source files directly from Listenarr's local staging path and skip external-client mark-import calls. | Must not poll clients, download DDL payloads, or perform deferred client cleanup. | Job-level retry via `DownloadProcessingJob.ScheduleRetry`; persisted checkpoints prevent completed file imports from being repeated during finalization retries. | Active jobs use a database-unique normalized download key; recent completed jobs retain the cooldown guard. A stale job for an already `Moved` download completes as a no-op. | `ScanQueueService` receives the post-import library scan request. | | `DownloadProcessingJobCleanupService` | `DownloadProcessingJobCleanupProcessor` | Deletes old terminal `DownloadProcessingJob` rows after the retention window so the processing table does not grow unbounded. | Must not import downloads, move files, poll clients, remove client items, or change download state. | Daily cadence after startup delay; non-fatal failures are logged by the shared worker cycle runner and retried on the next cycle. | Only terminal `Completed`/`Failed` jobs older than retention are removed; active `Pending`, `Processing`, and `Retry` jobs remain untouched even when old. | `IDownloadProcessingJobService.CleanupOldJobsAsync` performs the cleanup. | -| `ScanBackgroundService` | `ScanJobProcessor` | Consumes scan jobs and reconciles audiobook files/metadata for the audiobook library path. | Must not move audiobook roots or import download payloads. | In-memory scan jobs can be requeued from failed/completed/queued status. | `ScanQueueService` dedupes queued/processing jobs by audiobook and path; explicit rescans are allowed after completion/failure. | Broadcasts library updates after reconciliation. | -| `MoveBackgroundService` | `MoveJobProcessor` | Owns audiobook filesystem relocation and move-job status transitions `Queued -> Processing -> Completed/Failed`. | Must not import downloads or rewrite scan ownership. | Failed jobs keep `AttemptCount` and can be requeued through `MoveQueueService`. | `MoveQueueService` uses async persistence plus a database-unique active deduplication key for audiobook and requested path; terminal transitions release the key. | Broadcasts library updates after a completed move. | +| `ScanBackgroundService` | `ScanJobProcessor` | Consumes preauthorized scan jobs, reconciles only files attributable to the audiobook within the persisted path boundary, and commits the authoritative move-scan handoff attempt before changing the in-memory scan state. | Must not move audiobook roots, import download payloads, manufacture path authority, claim ambiguous files, or delete missing rows from a focused or incomplete scan. | Ordinary in-memory scans can be requeued explicitly with their original identity and authority scope. Move-owned scans use a durable `MoveScanHandoff` lease and attempt generation; failed handoffs can be explicitly reopened and later attempts supersede stale workers. | `ScanQueueService` keeps database work outside its short in-memory queue gate and dedupes by audiobook, endpoint identity, correlation, and reconciliation authority. `MoveScanHandoffRecoveryService` atomically claims pending or expired handoffs, and terminal handoff updates are fenced by attempt generation and database idempotency keys. | Broadcasts library updates only after durable terminal completion. Move completion creates one database-unique `MoveScanHandoff`; immediate dispatch and periodic recovery use the same claim path and persisted target identity. | +| `MoveBackgroundService` | `MoveJobProcessor` | Owns audiobook filesystem relocation and move-job transitions `Queued/RetryScheduled -> Running -> Completed/Failed/NeedsAttention/Superseded`, including immutable tracked-file manifest checkpoints, target scaffolding, filesystem ownership evidence, metadata rebasing, artifact cleanup, and completion handoffs. | Must not import downloads, infer ownership from `BasePath`, create manifest entries during execution, move foreign sibling content, or claim scan execution ownership. It may request a post-move scan only through the durable scan handoff store. | Transient filesystem and completion-handoff failures use persisted exponential backoff with jitter and a bounded automatic retry count. Exhaustion transitions to `NeedsAttention`; explicit manual requeue validates the persisted source, target, complete identities, and manifest before resetting the retry budget while preserving lease generation fencing. Legacy or manifestless jobs remain `NeedsAttention` unless reconciliation can prove a safe terminal outcome. | `MoveQueueService` uses async persistence plus a database-unique versioned key over audiobook, source endpoint, target endpoint, and manifest digest. `IMoveExecutionStore` translates provider failures and fences every mutation/checkpoint by lease generation. Structured markers, immutable manifests, tombstoned scaffold cleanup, and persisted target scaffolding make replay idempotent. Ordinary foreign content is preserved; only manifest-owned paths and Listenarr-owned scaffolding are eligible for cleanup. Move history, one unique `MoveScanHandoff`, and terminal move state are committed atomically for genuine completions. | After terminal commit, performs durable scan-handoff dispatch and best-effort webhooks, toasts, and audiobook broadcasts outside the per-audiobook lock. | | `MovedDownloadCleanupService` | `MovedDownloadCleanupProcessor` | Owns deferred download-client cleanup only for `Moved` downloads whose client policy requests cleanup and whose import is proven durable by a completed processing job, `LastImportedAt`, imported unified history, legacy imported download history, or old legacy `Moved` state. It removes the operational DB record only after configured client cleanup succeeds; the `none` policy retains the imported record. Legacy `Moved`-state proof may remove stale client/DB state but must not authorize external file deletion. | Must never import files, clean up an uncommitted import, delete history, or change a download back out of `Moved`. | Polls on the configured interval and retains failed cleanup records for future retries. | Cleanup attempts share the import correlation ID and remain in append-only history after operational records are removed. | Download-client gateway removes eligible client items. | | `QueueMonitorService` | `QueueMonitorProcessor` | None; it observes external queue snapshots and emits SignalR updates. | Must not persist download/import/scan state. | Adaptive polling interval based on queue activity. | Snapshot comparison suppresses duplicate broadcasts. | `DownloadHub` receives `QueueUpdate` messages. | | `AutomaticSearchService` | `AutomaticSearchProcessor` | Owns periodic wanted-item search decisions and download submission requests. | Must not import downloads, move files, or mark scan state. | Runs every 6 hours after startup delay; one failed audiobook does not stop the cycle. | Active-download and cutoff-quality checks prevent duplicate active work on replay. | `IDownloadService.StartDownloadAsync` creates the download handoff. | diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 724ebc8bd..af5df7acc 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -414,5 +414,6 @@ If you have any questions about contributing, please: 4. In `listenarr.api/Program.cs` call the infrastructure registration extension instead of registering types inline. 5. Delete the old API placeholder files and run `dotnet test` to verify no regressions. - Add a small DI/registration unit test (DependencyInjectionTests) that asserts required services are resolvable; run it early in CI to catch layering regressions. +- Create EF Core migrations with `dotnet ef migrations add` only. Do not hand-author migration `.cs`, `.Designer.cs`, or model snapshot files; generated migrations may be reviewed, but the scaffold is the source of truth so EF discovery metadata and accumulated snapshots stay complete. Thank you for contributing to Listenarr! 🎵📚 diff --git a/fe/src/__tests__/AddLibraryModal.relativePath.spec.ts b/fe/src/__tests__/AddLibraryModal.relativePath.spec.ts index be2854918..532649da4 100644 --- a/fe/src/__tests__/AddLibraryModal.relativePath.spec.ts +++ b/fe/src/__tests__/AddLibraryModal.relativePath.spec.ts @@ -28,6 +28,7 @@ vi.mock('@/services/api', () => ({ getApplicationSettings: vi.fn().mockResolvedValue({ outputPath: 'C:\\root' }), getQualityProfiles: vi.fn().mockResolvedValue([]), getRootFolders: vi.fn().mockResolvedValue([]), + addToLibrary: vi.fn().mockResolvedValue({ audiobook: { id: 1 } }), }, })) @@ -41,6 +42,36 @@ const fakeBook = { } describe('AddLibraryModal relative path derivation', () => { + it('shows and submits the same normalized effective destination', async () => { + const { apiService } = await import('@/services/api') + const wrapper = mount(AddLibraryModal, { + props: { + visible: false, + book: fakeBook, + }, + attachTo: document.body, + global: { + plugins: [(await import('pinia')).createPinia()], + }, + }) + + await wrapper.setProps({ visible: true }) + await new Promise((resolve) => setTimeout(resolve, 10)) + const input = wrapper.get('input.relative-input') + await input.setValue('Author/Title') + await wrapper.vm.$nextTick() + + const preview = wrapper.get('[data-testid="effective-destination"]').text() + expect(preview).toContain('C:\\root\\Author\\Title') + + await (wrapper.vm as unknown as { addToLibrary: () => Promise }).addToLibrary() + + expect(apiService.addToLibrary).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ destinationPath: 'C:\\root\\Author\\Title' }), + ) + }) + it('shows relative path (full minus root) when preview returns fullPath and root configured', async () => { const wrapper = mount(AddLibraryModal, { props: { diff --git a/fe/src/__tests__/BulkEditModal.results.spec.ts b/fe/src/__tests__/BulkEditModal.results.spec.ts new file mode 100644 index 000000000..c140364d8 --- /dev/null +++ b/fe/src/__tests__/BulkEditModal.results.spec.ts @@ -0,0 +1,126 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { mount } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import BulkEditModal from '@/components/domain/collection/BulkEditModal.vue' +import { executeBulkEdit } from '@/utils/bulkEditOrchestration' + +const success = vi.fn() +const error = vi.fn() +const info = vi.fn() + +vi.mock('@/services/toastService', () => ({ + useToast: () => ({ success, error, info }), +})) + +vi.mock('@/utils/bulkEditOrchestration', () => ({ + executeBulkEdit: vi.fn(), +})) + +const executeBulkEditMock = vi.mocked(executeBulkEdit) + +describe('BulkEditModal results', () => { + beforeEach(() => { + vi.clearAllMocks() + const pinia = createPinia() + setActivePinia(pinia) + }) + + it('keeps the modal open and does not emit saved when any item fails', async () => { + executeBulkEditMock.mockResolvedValue({ + results: [ + { id: 1, success: true, errors: [] }, + { id: 2, success: false, errors: ['queue unavailable'] }, + ], + }) + const pinia = createPinia() + setActivePinia(pinia) + const wrapper = mount(BulkEditModal, { + props: { + isOpen: true, + selectedCount: 2, + selectedIds: new Set([1, 2]), + }, + global: { + plugins: [pinia], + stubs: { + Modal: { template: '
' }, + ModalBody: { template: '
' }, + ModalHeader: true, + MoveAudiobookModal: true, + RootFolderSelect: true, + Checkbox: true, + }, + }, + }) + const vm = wrapper.vm as unknown as { + formData: { monitored: boolean | null } + handleSave: () => Promise + showResults: boolean + results: Array<{ id: number; success: boolean; errors: string[] }> + } + vm.formData.monitored = true + + await vm.handleSave() + + expect(vm.showResults).toBe(true) + expect(vm.results).toEqual([ + { id: 1, success: true, errors: [] }, + { id: 2, success: false, errors: ['queue unavailable'] }, + ]) + expect(error).toHaveBeenCalledWith( + 'Bulk update incomplete', + expect.stringContaining('1 succeeded and 1 failed'), + ) + expect(wrapper.emitted('saved')).toBeUndefined() + expect(wrapper.emitted('close')).toBeUndefined() + }) + + it('emits saved and closes only when every item succeeds', async () => { + executeBulkEditMock.mockResolvedValue({ + results: [ + { id: 1, success: true, errors: [] }, + { id: 2, success: true, errors: [] }, + ], + }) + const pinia = createPinia() + setActivePinia(pinia) + const wrapper = mount(BulkEditModal, { + props: { + isOpen: true, + selectedCount: 2, + selectedIds: new Set([1, 2]), + }, + global: { + plugins: [pinia], + stubs: { + Modal: { template: '
' }, + ModalBody: { template: '
' }, + ModalHeader: true, + MoveAudiobookModal: true, + RootFolderSelect: true, + Checkbox: true, + }, + }, + }) + const vm = wrapper.vm as unknown as { + formData: { monitored: boolean | null } + handleSave: () => Promise + } + vm.formData.monitored = true + + await vm.handleSave() + + expect(success).toHaveBeenCalledWith('Bulk update', 'Updated 2 audiobook(s)') + expect(wrapper.emitted('saved')).toHaveLength(1) + expect(wrapper.emitted('close')).toHaveLength(1) + }) +}) diff --git a/fe/src/__tests__/EditAudiobookModal.moveOptions.spec.ts b/fe/src/__tests__/EditAudiobookModal.moveOptions.spec.ts index 30f882c7b..a4881066f 100644 --- a/fe/src/__tests__/EditAudiobookModal.moveOptions.spec.ts +++ b/fe/src/__tests__/EditAudiobookModal.moveOptions.spec.ts @@ -18,6 +18,27 @@ import { mount } from '@vue/test-utils' import { vi, describe, it, expect, beforeEach } from 'vitest' +type MoveJobUpdate = { jobId?: string; status?: string; target?: string; error?: string } + +const toastMocks = vi.hoisted(() => ({ + info: vi.fn(), + success: vi.fn(), + error: vi.fn(), +})) + +const signalRMocks = vi.hoisted(() => { + const state = { + callback: null as ((job: MoveJobUpdate) => void) | null, + unsubscribe: vi.fn(), + onMoveJobUpdate: vi.fn(), + } + state.onMoveJobUpdate.mockImplementation((callback: (job: MoveJobUpdate) => void) => { + state.callback = callback + return state.unsubscribe + }) + return state +}) + vi.mock('@/services/api', () => ({ apiService: { getAudiobook: vi.fn().mockImplementation(async (id: number) => ({ id })), @@ -32,12 +53,12 @@ vi.mock('@/services/api', () => ({ })) vi.mock('@/services/toastService', () => ({ - useToast: () => ({ info: vi.fn(), success: vi.fn(), error: vi.fn() }), + useToast: () => toastMocks, })) vi.mock('@/services/signalr', () => ({ signalRService: { - onMoveJobUpdate: vi.fn(() => () => {}), + onMoveJobUpdate: signalRMocks.onMoveJobUpdate, }, })) @@ -48,6 +69,7 @@ const audiobook = { title: 'Sample', authors: ['Author'], basePath: 'C:\\root\\Some Author\\Some Title', + imageUrl: 'C:\\root\\Some Author\\Some Title\\cover.jpg', monitored: true, tags: [], } @@ -55,9 +77,14 @@ const audiobook = { describe('EditAudiobookModal move options', () => { beforeEach(() => { vi.clearAllMocks() + signalRMocks.callback = null + signalRMocks.onMoveJobUpdate.mockImplementation((callback: (job: MoveJobUpdate) => void) => { + signalRMocks.callback = callback + return signalRMocks.unsubscribe + }) }) - it('Change without moving should update audiobook and not call move API', async () => { + it('Change without moving should persist metadata and identifiers before the destination update', async () => { const wrapper = mount(EditAudiobookModal, { props: { isOpen: true, audiobook }, attachTo: document.body, @@ -67,10 +94,20 @@ describe('EditAudiobookModal move options', () => { // let init settle await new Promise((r) => setTimeout(r, 200)) - // Ensure there is a detectable change: set an explicit custom root and flip monitored + // Ensure there is a detectable change: set an explicit custom root and change title ;(wrapper.vm as unknown).selectedRootId = 0 ;(wrapper.vm as unknown).customRootPath = 'C:\\root\\New Author\\New Book' - ;(wrapper.vm as unknown).formData.monitored = false + ;(wrapper.vm as unknown).formData.title = 'Sample Updated' + ;(wrapper.vm as unknown).formData.identifiers = [ + { + localKey: 'new-asin', + type: 'Asin', + value: 'B0TEST1234', + region: 'us', + isPrimary: true, + source: 'Manual', + }, + ] await wrapper.vm.$nextTick() // Start save flow and resolve the in-component confirmation promise by @@ -85,8 +122,132 @@ describe('EditAudiobookModal move options', () => { await new Promise((r) => setTimeout(r, 50)) const { apiService } = await import('@/services/api') + expect(apiService.moveAudiobook).toHaveBeenCalledTimes(1) + expect(apiService.moveAudiobook).toHaveBeenCalledWith(1, 'C:/root/New Author/New Book', { + sourcePath: 'C:\\root\\Some Author\\Some Title', + moveFiles: false, + deleteEmptySource: false, + }) expect(apiService.updateAudiobook).toHaveBeenCalledTimes(1) - expect(apiService.moveAudiobook).toHaveBeenCalledTimes(0) + const updatePayload = vi.mocked(apiService.updateAudiobook).mock.calls[0][1] as Record< + string, + unknown + > + expect(updatePayload.title).toBe('Sample Updated') + expect(Object.prototype.hasOwnProperty.call(updatePayload, 'basePath')).toBe(false) + expect(Object.prototype.hasOwnProperty.call(updatePayload, 'imageUrl')).toBe(false) + expect(apiService.updateAudiobookIdentifiers).toHaveBeenCalledTimes(1) + expect(vi.mocked(apiService.updateAudiobook).mock.invocationCallOrder[0]).toBeLessThan( + vi.mocked(apiService.updateAudiobookIdentifiers).mock.invocationCallOrder[0], + ) + expect( + vi.mocked(apiService.updateAudiobookIdentifiers).mock.invocationCallOrder[0], + ).toBeLessThan(vi.mocked(apiService.moveAudiobook).mock.invocationCallOrder[0]) + }) + + it('reports partial success when metadata saves but the destination update fails', async () => { + const { apiService } = await import('@/services/api') + vi.mocked(apiService.moveAudiobook).mockRejectedValueOnce(new Error('queue unavailable')) + const wrapper = mount(EditAudiobookModal, { + props: { isOpen: true, audiobook }, + attachTo: document.body, + global: { plugins: [(await import('pinia')).createPinia()] }, + }) + + await new Promise((r) => setTimeout(r, 200)) + ;(wrapper.vm as unknown).selectedRootId = 0 + ;(wrapper.vm as unknown).customRootPath = 'C:\\root\\New Author\\New Book' + ;(wrapper.vm as unknown).formData.title = 'Saved Before Move Failure' + await wrapper.vm.$nextTick() + + const savePromise = (wrapper.vm as unknown).handleSave() + await new Promise((r) => setTimeout(r, 10)) + const resolver = (wrapper.vm as unknown).moveConfirmResolver + if (resolver) resolver({ proceed: true, moveFiles: true, deleteEmptySource: true }) + await savePromise + + expect(apiService.updateAudiobook).toHaveBeenCalledWith( + 1, + expect.objectContaining({ title: 'Saved Before Move Failure' }), + ) + expect(apiService.moveAudiobook).toHaveBeenCalledTimes(1) + expect(toastMocks.error).toHaveBeenCalledWith( + 'Move failed', + 'Your metadata changes were saved, but the destination update was not queued.', + ) + expect(wrapper.emitted('saved')).toBeUndefined() + }) + + it('shows a structured destination rejection inline with the effective path', async () => { + const { apiService } = await import('@/services/api') + const rejectedPath = 'C:/outside/New Author/New Book' + vi.mocked(apiService.moveAudiobook).mockRejectedValueOnce( + Object.assign(new Error('API error'), { + status: 400, + body: JSON.stringify({ + code: 'destination_path_outside_roots', + field: 'destinationPath', + message: 'DestinationPath must be inside a configured root folder or output path', + resolvedDestination: rejectedPath, + }), + }), + ) + const wrapper = mount(EditAudiobookModal, { + props: { isOpen: true, audiobook }, + attachTo: document.body, + global: { plugins: [(await import('pinia')).createPinia()] }, + }) + + await new Promise((resolve) => setTimeout(resolve, 200)) + ;(wrapper.vm as unknown).selectedRootId = 0 + ;(wrapper.vm as unknown).customRootPath = 'C:\\outside\\New Author\\New Book' + await wrapper.vm.$nextTick() + + const savePromise = (wrapper.vm as unknown).handleSave() + await new Promise((resolve) => setTimeout(resolve, 10)) + const resolver = (wrapper.vm as unknown).moveConfirmResolver + if (resolver) resolver({ proceed: true, moveFiles: true, deleteEmptySource: true }) + await savePromise + await wrapper.vm.$nextTick() + + expect(wrapper.get('[data-testid="effective-destination"]').text()).toContain(rejectedPath) + expect(wrapper.text()).toContain( + 'DestinationPath must be inside a configured root folder or output path', + ) + expect(toastMocks.error).toHaveBeenCalledWith( + 'Invalid destination', + 'DestinationPath must be inside a configured root folder or output path', + ) + expect(wrapper.emitted('saved')).toBeUndefined() + }) + + it('Destination-only change without moving should call move API and skip metadata update', async () => { + const wrapper = mount(EditAudiobookModal, { + props: { isOpen: true, audiobook }, + attachTo: document.body, + global: { plugins: [(await import('pinia')).createPinia()] }, + }) + + await new Promise((r) => setTimeout(r, 200)) + ;(wrapper.vm as unknown).selectedRootId = 0 + ;(wrapper.vm as unknown).customRootPath = 'C:\\root\\New Author\\New Book' + await wrapper.vm.$nextTick() + + const savePromise = (wrapper.vm as unknown).handleSave() + await new Promise((r) => setTimeout(r, 10)) + const resolver = (wrapper.vm as unknown).moveConfirmResolver + if (resolver) resolver({ proceed: true, moveFiles: false, deleteEmptySource: true }) + await savePromise + await new Promise((r) => setTimeout(r, 50)) + + const { apiService } = await import('@/services/api') + expect(apiService.updateAudiobook).toHaveBeenCalledTimes(0) + expect(apiService.moveAudiobook).toHaveBeenCalledTimes(1) + expect(apiService.moveAudiobook).toHaveBeenCalledWith(1, 'C:/root/New Author/New Book', { + sourcePath: 'C:\\root\\Some Author\\Some Title', + moveFiles: false, + deleteEmptySource: false, + }) }) it('Move should call move API with deleteEmptySource true by default', async () => { @@ -117,14 +278,182 @@ describe('EditAudiobookModal move options', () => { const { apiService } = await import('@/services/api') expect(apiService.updateAudiobook).toHaveBeenCalledTimes(1) + expect(apiService.updateAudiobook).toHaveBeenCalledWith( + 1, + expect.not.objectContaining({ basePath: expect.anything() }), + ) expect(apiService.moveAudiobook).toHaveBeenCalledTimes(1) + expect(apiService.moveAudiobook).toHaveBeenCalledWith(1, 'C:/root/New Author/New Book', { + sourcePath: 'C:\\root\\Some Author\\Some Title', + moveFiles: true, + deleteEmptySource: true, + }) + }) + + it('Destination with parent traversal should be invalid and not call save APIs', async () => { + const wrapper = mount(EditAudiobookModal, { + props: { isOpen: true, audiobook }, + attachTo: document.body, + global: { plugins: [(await import('pinia')).createPinia()] }, + }) + + await new Promise((r) => setTimeout(r, 200)) + ;(wrapper.vm as unknown).selectedRootId = 0 + ;(wrapper.vm as unknown).customRootPath = 'C:\\root\\Some Author\\Some Title\\..' + await wrapper.vm.$nextTick() + + expect(wrapper.text()).toContain('Path traversal is not allowed in the destination folder') + expect( + wrapper.find('button[aria-label="Save destination"]').attributes('disabled'), + ).toBeDefined() + + await (wrapper.vm as unknown).handleSave() + await new Promise((r) => setTimeout(r, 50)) + + const { apiService } = await import('@/services/api') + expect(apiService.updateAudiobook).toHaveBeenCalledTimes(0) + expect(apiService.moveAudiobook).toHaveBeenCalledTimes(0) + }) + + it('Destination segment with trailing whitespace should be invalid and not call save APIs', async () => { + const wrapper = mount(EditAudiobookModal, { + props: { isOpen: true, audiobook }, + attachTo: document.body, + global: { plugins: [(await import('pinia')).createPinia()] }, + }) + + await new Promise((r) => setTimeout(r, 200)) + ;(wrapper.vm as unknown).selectedRootId = 0 + ;(wrapper.vm as unknown).customRootPath = 'C:\\root\\Some Author\\Some Title\\test ' + await wrapper.vm.$nextTick() + + expect(wrapper.text()).toContain( + 'Windows destination folder segments cannot end with a space or period', + ) + expect( + wrapper.find('button[aria-label="Save destination"]').attributes('disabled'), + ).toBeDefined() + + await (wrapper.vm as unknown).handleSave() + await new Promise((r) => setTimeout(r, 50)) + + const { apiService } = await import('@/services/api') + expect(apiService.updateAudiobook).toHaveBeenCalledTimes(0) + expect(apiService.moveAudiobook).toHaveBeenCalledTimes(0) + }) + + it('Destination inside current source should be allowed as a content move', async () => { + const wrapper = mount(EditAudiobookModal, { + props: { isOpen: true, audiobook }, + attachTo: document.body, + global: { plugins: [(await import('pinia')).createPinia()] }, + }) + + await new Promise((r) => setTimeout(r, 200)) + ;(wrapper.vm as unknown).selectedRootId = 0 + ;(wrapper.vm as unknown).customRootPath = 'C:\\root\\Some Author\\Some Title\\ test' + await wrapper.vm.$nextTick() + + expect(wrapper.text()).not.toContain('Source and destination folders cannot overlap') + expect( + wrapper.find('button[aria-label="Save destination"]').attributes('disabled'), + ).toBeUndefined() + + const savePromise = (wrapper.vm as unknown).handleSave() + await new Promise((r) => setTimeout(r, 10)) + const resolver = (wrapper.vm as unknown).moveConfirmResolver + if (resolver) resolver({ proceed: true, moveFiles: true, deleteEmptySource: true }) + await savePromise + await new Promise((r) => setTimeout(r, 50)) + + const { apiService } = await import('@/services/api') + expect(apiService.updateAudiobook).toHaveBeenCalledTimes(0) + expect(apiService.moveAudiobook).toHaveBeenCalledWith( + 1, + 'C:/root/Some Author/Some Title/ test', + { + sourcePath: 'C:\\root\\Some Author\\Some Title', + moveFiles: true, + deleteEmptySource: true, + }, + ) + }) + + it('Windows destination segment with leading whitespace outside source should be allowed', async () => { + const wrapper = mount(EditAudiobookModal, { + props: { isOpen: true, audiobook }, + attachTo: document.body, + global: { plugins: [(await import('pinia')).createPinia()] }, + }) + + await new Promise((r) => setTimeout(r, 200)) + ;(wrapper.vm as unknown).selectedRootId = 0 + ;(wrapper.vm as unknown).customRootPath = 'C:\\root\\Some Author\\Other Title\\ test' + await wrapper.vm.$nextTick() + + expect(wrapper.text()).not.toContain('Windows destination folder segments cannot end') + expect( + wrapper.find('button[aria-label="Save destination"]').attributes('disabled'), + ).toBeUndefined() + + const savePromise = (wrapper.vm as unknown).handleSave() + await new Promise((r) => setTimeout(r, 10)) + const resolver = (wrapper.vm as unknown).moveConfirmResolver + if (resolver) resolver({ proceed: true, moveFiles: true, deleteEmptySource: true }) + await savePromise + await new Promise((r) => setTimeout(r, 50)) + + const { apiService } = await import('@/services/api') expect(apiService.moveAudiobook).toHaveBeenCalledWith( - expect.anything(), - expect.anything(), - expect.objectContaining({ moveFiles: true, deleteEmptySource: true }), + 1, + 'C:/root/Some Author/Other Title/ test', + { + sourcePath: 'C:\\root\\Some Author\\Some Title', + moveFiles: true, + deleteEmptySource: true, + }, ) }) + it('Move-only destination changes should enqueue move without pre-saving BasePath', async () => { + const wrapper = mount(EditAudiobookModal, { + props: { isOpen: true, audiobook }, + attachTo: document.body, + global: { plugins: [(await import('pinia')).createPinia()] }, + }) + + await new Promise((r) => setTimeout(r, 200)) + ;(wrapper.vm as unknown).selectedRootId = 0 + ;(wrapper.vm as unknown).customRootPath = 'C:\\root\\New Author\\New Book' + await wrapper.vm.$nextTick() + + const savePromise = (wrapper.vm as unknown).handleSave() + await new Promise((r) => setTimeout(r, 10)) + const resolver = (wrapper.vm as unknown).moveConfirmResolver + if (resolver) resolver({ proceed: true, moveFiles: true, deleteEmptySource: true }) + await savePromise + await new Promise((r) => setTimeout(r, 50)) + + const { apiService } = await import('@/services/api') + const { useMoveJobsStore } = await import('@/stores/moveJobs') + const moveJobsStore = useMoveJobsStore() + expect(apiService.updateAudiobook).toHaveBeenCalledTimes(0) + expect(apiService.moveAudiobook).toHaveBeenCalledWith(1, 'C:/root/New Author/New Book', { + sourcePath: 'C:\\root\\Some Author\\Some Title', + moveFiles: true, + deleteEmptySource: true, + }) + expect(moveJobsStore.trackedById['job-1']).toEqual({ + jobId: 'job-1', + audiobookId: 1, + status: 'Queued', + target: 'C:/root/New Author/New Book', + }) + expect(signalRMocks.onMoveJobUpdate).toHaveBeenCalledTimes(1) + expect(wrapper.emitted('saved')).toHaveLength(1) + expect(wrapper.emitted('close')).toHaveLength(1) + }) + it('Edition-only changes should persist through updateAudiobook', async () => { const wrapper = mount(EditAudiobookModal, { props: { isOpen: true, audiobook }, @@ -147,6 +476,44 @@ describe('EditAudiobookModal move options', () => { ) }) + it('metadata edit with separator-only custom Windows path does not enqueue a move', async () => { + const wrapper = mount(EditAudiobookModal, { + props: { isOpen: true, audiobook }, + attachTo: document.body, + global: { plugins: [(await import('pinia')).createPinia()] }, + }) + + await new Promise((r) => setTimeout(r, 200)) + + const vm = wrapper.vm as unknown as { + selectedRootId: number + customRootPath: string + formData: { title: string } + handleSave: () => Promise + } + vm.selectedRootId = 0 + vm.customRootPath = 'C:/root/Some Author/Some Title' + vm.formData.title = 'Updated Sample' + await wrapper.vm.$nextTick() + + expect(wrapper.text()).not.toContain('Destination folder must be different') + + await vm.handleSave() + await new Promise((r) => setTimeout(r, 50)) + + const { apiService } = await import('@/services/api') + expect(apiService.updateAudiobook).toHaveBeenCalledTimes(1) + expect(apiService.updateAudiobook).toHaveBeenCalledWith( + 1, + expect.objectContaining({ title: 'Updated Sample' }), + ) + expect(apiService.updateAudiobook).toHaveBeenCalledWith( + 1, + expect.not.objectContaining({ basePath: expect.anything() }), + ) + expect(apiService.moveAudiobook).toHaveBeenCalledTimes(0) + }) + it('metadata changes should persist through updateAudiobook', async () => { const wrapper = mount(EditAudiobookModal, { props: { diff --git a/fe/src/__tests__/EditAudiobookModal.relativePath.spec.ts b/fe/src/__tests__/EditAudiobookModal.relativePath.spec.ts index aa36c94e2..527050d63 100644 --- a/fe/src/__tests__/EditAudiobookModal.relativePath.spec.ts +++ b/fe/src/__tests__/EditAudiobookModal.relativePath.spec.ts @@ -19,6 +19,16 @@ import { mount } from '@vue/test-utils' import { vi, describe, it, expect } from 'vitest' import { nextTick } from 'vue' +const toastMocks = vi.hoisted(() => ({ + error: vi.fn(), + info: vi.fn(), + success: vi.fn(), +})) + +vi.mock('@/services/toastService', () => ({ + useToast: () => toastMocks, +})) + vi.mock('@/services/api', () => ({ apiService: { getAudiobook: vi.fn().mockImplementation(async (id: number) => ({ id })), @@ -31,6 +41,7 @@ vi.mock('@/services/api', () => ({ }, })) +import { apiService } from '@/services/api' import EditAudiobookModal from '@/components/domain/audiobook/EditAudiobookModal.vue' const audiobook = { @@ -114,6 +125,43 @@ describe('EditAudiobookModal relative path calculation', () => { expect((wrapper.vm as unknown).formData.relativePath).toBe('') }) + it('selects the most specific configured root for nested root folders', async () => { + vi.mocked(apiService.getRootFolders).mockResolvedValueOnce([ + { + id: 1, + name: 'Broad root', + path: 'C:\\root', + isDefault: true, + resolvedCaseSensitivity: 'Insensitive', + }, + { + id: 2, + name: 'Nested sensitive root', + path: 'C:\\root\\Sensitive', + isDefault: false, + resolvedCaseSensitivity: 'Sensitive', + }, + ]) + const wrapper = mount(EditAudiobookModal, { + props: { + isOpen: true, + audiobook: { + ...audiobook, + basePath: 'C:\\root\\Sensitive\\Book', + }, + }, + attachTo: document.body, + global: { + plugins: [(await import('pinia')).createPinia()], + }, + }) + + await new Promise((resolve) => setTimeout(resolve, 10)) + + expect((wrapper.vm as unknown).selectedRootId).toBe(2) + expect((wrapper.vm as unknown).formData.relativePath).toBe('Book') + }) + it('normalizes absolute path to relative when Done is clicked', async () => { const wrapper = mount(EditAudiobookModal, { props: { @@ -137,6 +185,109 @@ describe('EditAudiobookModal relative path calculation', () => { expect((wrapper.vm as unknown).formData.relativePath).toBe('New Author\\New Title') }) + it('rejects an unrelated absolute path without redirecting it beneath the selected root', async () => { + const wrapper = mount(EditAudiobookModal, { + props: { + isOpen: true, + audiobook, + }, + attachTo: document.body, + global: { + plugins: [(await import('pinia')).createPinia()], + }, + }) + + await new Promise((resolve) => setTimeout(resolve, 10)) + ;(wrapper.vm as unknown).startEditingDestination() + ;(wrapper.vm as unknown).formData.relativePath = 'D:\\Backup\\root\\Redirected Title' + + await (wrapper.vm as unknown).finishEditingDestination() + + expect((wrapper.vm as unknown).formData.relativePath).toBe('D:\\Backup\\root\\Redirected Title') + expect((wrapper.vm as unknown).editingDestination).toBe(true) + expect(toastMocks.error).toHaveBeenCalledWith( + 'Invalid destination', + 'An absolute destination must be inside the selected root folder.', + ) + }) + + it('accepts forward-slash UNC roots and normalizes matching absolute input', async () => { + vi.mocked(apiService.getRootFolders).mockResolvedValueOnce([ + { + id: 7, + name: 'UNC root', + path: '//server/share/Books', + pathSyntax: 'Windows', + isDefault: true, + resolvedCaseSensitivity: 'Insensitive', + }, + ]) + const wrapper = mount(EditAudiobookModal, { + props: { + isOpen: true, + audiobook: { + ...audiobook, + basePath: '//server/share/Books/Author/Title', + }, + }, + attachTo: document.body, + global: { + plugins: [(await import('pinia')).createPinia()], + }, + }) + + await new Promise((resolve) => setTimeout(resolve, 10)) + + expect((wrapper.vm as unknown).selectedRootId).toBe(7) + expect((wrapper.vm as unknown).formData.relativePath).toBe('Author/Title') + ;(wrapper.vm as unknown).startEditingDestination() + ;(wrapper.vm as unknown).formData.relativePath = '\\\\SERVER\\SHARE\\Books\\Other Title' + await (wrapper.vm as unknown).finishEditingDestination() + + expect((wrapper.vm as unknown).formData.relativePath).toBe('Other Title') + expect((wrapper.vm as unknown).editingDestination).toBe(false) + }) + + it('treats a leading backslash as relative under an explicit Unix root', async () => { + vi.mocked(apiService.getRootFolders).mockResolvedValueOnce([ + { + id: 8, + name: 'Unix root', + path: '/library', + pathSyntax: 'Unix', + isDefault: true, + resolvedCaseSensitivity: 'Sensitive', + }, + ]) + const wrapper = mount(EditAudiobookModal, { + props: { + isOpen: true, + audiobook: { + ...audiobook, + basePath: '/library/Author/Title', + }, + }, + attachTo: document.body, + global: { + plugins: [(await import('pinia')).createPinia()], + }, + }) + + await new Promise((resolve) => setTimeout(resolve, 10)) + toastMocks.error.mockClear() + ;(wrapper.vm as unknown).startEditingDestination() + ;(wrapper.vm as unknown).formData.relativePath = '\\Chapter' + + await (wrapper.vm as unknown).finishEditingDestination() + + expect((wrapper.vm as unknown).formData.relativePath).toBe('\\Chapter') + expect((wrapper.vm as unknown).editingDestination).toBe(false) + expect(toastMocks.error).not.toHaveBeenCalledWith( + 'Invalid destination', + 'An absolute destination must be inside the selected root folder.', + ) + }) + it('preserves a user-typed relative path after Done and reopen', async () => { const wrapper = mount(EditAudiobookModal, { props: { diff --git a/fe/src/__tests__/RenamePreviewModal.spec.ts b/fe/src/__tests__/RenamePreviewModal.spec.ts index 4a4801138..701dd4bf1 100644 --- a/fe/src/__tests__/RenamePreviewModal.spec.ts +++ b/fe/src/__tests__/RenamePreviewModal.spec.ts @@ -19,7 +19,14 @@ import { flushPromises, mount } from '@vue/test-utils' import { beforeEach, describe, expect, it, vi } from 'vitest' import RenamePreviewModal from '@/components/domain/organize/RenamePreviewModal.vue' import { apiService } from '@/services/api' -import type { RenamePreview } from '@/types' +import type { RenamePathSemanticsSnapshot, RenamePreview } from '@/types' + +const currentFolderSemantics: RenamePathSemanticsSnapshot = { + syntax: 'Windows', + caseSensitivity: 'Insensitive', + requestedMode: 'Auto', + boundaryPath: 'D:\\test\\Author\\Alchemised', +} describe('RenamePreviewModal', () => { beforeEach(() => { @@ -32,6 +39,7 @@ describe('RenamePreviewModal', () => { audiobookId: 7, audiobookTitle: 'Alchemised', currentFolderPath: 'D:\\test\\Author\\Alchemised', + currentFolderSemantics, newFolderPath: 'D:\\test\\Author\\Alchemised test', folderChanged: true, hasChanges: true, @@ -69,4 +77,66 @@ describe('RenamePreviewModal', () => { expect(wrapper.text()).toContain('New') expect(wrapper.find('.btn.btn-primary').text()).toContain('Organize 1') }) + + it('sends expected current state and displays stale-preview conflicts as failures', async () => { + vi.mocked(apiService.previewRename).mockResolvedValue([ + { + audiobookId: 7, + audiobookTitle: 'Alchemised', + currentFolderPath: 'D:\\test\\Author\\Alchemised', + currentFolderSemantics, + newFolderPath: 'D:\\test\\Author\\Alchemised test', + folderChanged: true, + hasChanges: true, + fileRenames: [ + { + fileId: 71, + currentPath: 'D:\\test\\Author\\Alchemised\\Alchemised.m4b', + newPath: 'D:\\test\\Author\\Alchemised test\\Alchemised test.m4b', + currentFilename: 'Alchemised.m4b', + newFilename: 'Alchemised test.m4b', + changed: true, + }, + ], + }, + ] satisfies RenamePreview[]) + vi.mocked(apiService.executeRename).mockResolvedValue([ + { + audiobookId: 7, + success: false, + conflict: true, + error: 'The audiobook folder changed after the organize preview was generated.', + renamedFiles: [], + }, + ]) + + const wrapper = mount(RenamePreviewModal, { + props: { + visible: true, + audiobookIds: [7], + }, + }) + await flushPromises() + + await wrapper.find('.btn.btn-primary').trigger('click') + await flushPromises() + + expect(apiService.executeRename).toHaveBeenCalledWith([ + { + audiobookId: 7, + currentFolderPath: 'D:\\test\\Author\\Alchemised', + currentFolderSemantics, + newFolderPath: 'D:\\test\\Author\\Alchemised test', + fileRenames: [ + { + fileId: 71, + currentPath: 'D:\\test\\Author\\Alchemised\\Alchemised.m4b', + newPath: 'D:\\test\\Author\\Alchemised test\\Alchemised test.m4b', + }, + ], + }, + ]) + expect(wrapper.find('.result-row.error').exists()).toBe(true) + expect(wrapper.text()).toContain('folder changed after the organize preview') + }) }) diff --git a/fe/src/__tests__/RootFolderFormModal.spec.ts b/fe/src/__tests__/RootFolderFormModal.spec.ts new file mode 100644 index 000000000..c1ad9016b --- /dev/null +++ b/fe/src/__tests__/RootFolderFormModal.spec.ts @@ -0,0 +1,477 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { mount } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import RootFolderFormModal from '@/components/settings/RootFolderFormModal.vue' +import { useRootFoldersStore } from '@/stores/rootFolders' +import { apiService } from '@/services/api' + +const success = vi.fn() +const error = vi.fn() + +vi.mock('@/services/toastService', () => ({ + useToast: () => ({ success, error }), +})) + +describe('RootFolderFormModal', () => { + beforeEach(() => { + vi.restoreAllMocks() + vi.clearAllMocks() + }) + + it('rejects a Windows drive-relative root before submission', async () => { + const pinia = createPinia() + setActivePinia(pinia) + const store = useRootFoldersStore() + const create = vi.spyOn(store, 'create') + const wrapper = mount(RootFolderFormModal, { + global: { + plugins: [pinia], + stubs: { + FolderBrowserModal: true, + }, + }, + }) + await wrapper.get('input[placeholder="Enter a name for this root folder"]').setValue('Library') + await wrapper.get('#root-path').setValue('C:') + + await (wrapper.vm as unknown as { save: () => Promise }).save() + + expect(create).not.toHaveBeenCalled() + expect(error).toHaveBeenCalledWith( + 'Validation Error', + expect.stringContaining('separator after the drive letter'), + ) + }) + + it('rejects a relative root before submission', async () => { + const pinia = createPinia() + setActivePinia(pinia) + const store = useRootFoldersStore() + const create = vi.spyOn(store, 'create') + const wrapper = mount(RootFolderFormModal, { + global: { + plugins: [pinia], + stubs: { + FolderBrowserModal: true, + }, + }, + }) + await wrapper.get('input[placeholder="Enter a name for this root folder"]').setValue('Library') + await wrapper.get('#root-path').setValue('relative/library') + + await (wrapper.vm as unknown as { save: () => Promise }).save() + + expect(create).not.toHaveBeenCalled() + expect(error).toHaveBeenCalledWith( + 'Validation Error', + expect.stringContaining('absolute directory path'), + ) + }) + + it('uses the edited unambiguous path syntax instead of stale root metadata', async () => { + const pinia = createPinia() + setActivePinia(pinia) + const wrapper = mount(RootFolderFormModal, { + props: { + root: { + id: 8, + name: 'Migrated Library', + path: '//server/share/Books', + pathSyntax: 'Windows', + isDefault: false, + }, + }, + global: { + plugins: [pinia], + stubs: { + FolderBrowserModal: true, + }, + }, + }) + await wrapper.get('#root-path').setValue('/srv/CON') + + await (wrapper.vm as unknown as { save: () => Promise }).save() + + expect(error).not.toHaveBeenCalled() + }) + + it('updates metadata directly for an equivalent Windows path', async () => { + const pinia = createPinia() + setActivePinia(pinia) + const store = useRootFoldersStore() + const root = { + id: 12, + name: 'Library', + path: 'C:\\Library', + pathSyntax: 'Windows' as const, + isDefault: false, + caseSensitivityMode: 'Auto' as const, + resolvedCaseSensitivity: 'Insensitive' as const, + pathIdentityState: 'Valid' as const, + } + store.folders = [root] + const update = vi.spyOn(store, 'update') + const updateMetadata = vi.spyOn(apiService, 'updateRootFolder').mockResolvedValue(root) + const relocate = vi.spyOn(apiService, 'changeRootFolderPath') + vi.spyOn(apiService, 'getRootFolders').mockResolvedValue([root]) + const wrapper = mount(RootFolderFormModal, { + props: { + root, + }, + global: { + plugins: [pinia], + stubs: { + FolderBrowserModal: true, + }, + }, + }) + await wrapper.get('#root-path').setValue('c:/library/') + + await (wrapper.vm as unknown as { save: () => Promise }).save() + + await vi.waitFor(() => expect(update).toHaveBeenCalledTimes(1)) + expect(update).toHaveBeenCalledWith(12, expect.objectContaining({ path: 'c:/library/' }), { + expectedCurrentPath: 'C:\\Library', + }) + expect(updateMetadata).toHaveBeenCalledWith( + 12, + expect.objectContaining({ path: 'C:\\Library' }), + ) + expect(relocate).not.toHaveBeenCalled() + expect(success).toHaveBeenCalledWith('Success', 'Root folder updated') + }) + + it('ignores stale resolved sensitivity when explicit persisted mode is sensitive', async () => { + const pinia = createPinia() + setActivePinia(pinia) + const store = useRootFoldersStore() + const root = { + id: 20, + name: 'Library', + path: 'C:\\Library', + pathSyntax: 'Windows' as const, + isDefault: false, + caseSensitivityMode: 'Sensitive' as const, + resolvedCaseSensitivity: 'Insensitive' as const, + pathIdentityState: 'Valid' as const, + } + store.folders = [root] + const update = vi.spyOn(store, 'update') + const wrapper = mount(RootFolderFormModal, { + props: { root }, + global: { + plugins: [pinia], + stubs: { FolderBrowserModal: true }, + }, + }) + await wrapper.get('#root-path').setValue('C:\\library') + + await (wrapper.vm as unknown as { save: () => Promise }).save() + + expect(update).not.toHaveBeenCalled() + expect((wrapper.vm as unknown as { showConfirm: boolean }).showConfirm).toBe(true) + }) + + it('fails closed when auto identity is unavailable despite stale insensitive resolution', async () => { + const pinia = createPinia() + setActivePinia(pinia) + const store = useRootFoldersStore() + const root = { + id: 21, + name: 'Library', + path: 'C:\\Library', + pathSyntax: 'Windows' as const, + isDefault: false, + caseSensitivityMode: 'Auto' as const, + resolvedCaseSensitivity: 'Insensitive' as const, + pathIdentityState: 'Unavailable' as const, + } + store.folders = [root] + const update = vi.spyOn(store, 'update') + const wrapper = mount(RootFolderFormModal, { + props: { root }, + global: { + plugins: [pinia], + stubs: { FolderBrowserModal: true }, + }, + }) + await wrapper.get('#root-path').setValue('C:\\library') + + await (wrapper.vm as unknown as { save: () => Promise }).save() + + expect(update).not.toHaveBeenCalled() + expect((wrapper.vm as unknown as { showConfirm: boolean }).showConfirm).toBe(true) + }) + + it('requires relocation confirmation when a sensitive persisted root changes only by case', async () => { + const pinia = createPinia() + setActivePinia(pinia) + const store = useRootFoldersStore() + const root = { + id: 14, + name: 'Library', + path: 'C:\\Library', + pathSyntax: 'Windows' as const, + isDefault: false, + caseSensitivityMode: 'Sensitive' as const, + resolvedCaseSensitivity: 'Sensitive' as const, + } + store.folders = [root] + const update = vi.spyOn(store, 'update') + const updateMetadata = vi.spyOn(apiService, 'updateRootFolder') + const relocate = vi.spyOn(apiService, 'changeRootFolderPath') + const wrapper = mount(RootFolderFormModal, { + props: { root }, + global: { + plugins: [pinia], + stubs: { FolderBrowserModal: true }, + }, + }) + await wrapper.get('#root-path').setValue('C:\\library') + await wrapper.get('#root-case-sensitivity').setValue('Insensitive') + + await (wrapper.vm as unknown as { save: () => Promise }).save() + + expect((wrapper.vm as unknown as { showConfirm: boolean }).showConfirm).toBe(true) + expect(update).not.toHaveBeenCalled() + expect(updateMetadata).not.toHaveBeenCalled() + expect(relocate).not.toHaveBeenCalled() + }) + + it('uses metadata update when an insensitive persisted root changes only by case', async () => { + const pinia = createPinia() + setActivePinia(pinia) + const store = useRootFoldersStore() + const root = { + id: 18, + name: 'Library', + path: 'C:\\Library', + pathSyntax: 'Windows' as const, + isDefault: false, + caseSensitivityMode: 'Insensitive' as const, + resolvedCaseSensitivity: 'Insensitive' as const, + } + store.folders = [root] + const updateMetadata = vi.spyOn(apiService, 'updateRootFolder').mockResolvedValue(root) + const relocate = vi.spyOn(apiService, 'changeRootFolderPath') + vi.spyOn(apiService, 'getRootFolders').mockResolvedValue([root]) + const wrapper = mount(RootFolderFormModal, { + props: { root }, + global: { + plugins: [pinia], + stubs: { FolderBrowserModal: true }, + }, + }) + await wrapper.get('#root-path').setValue('C:\\library') + await wrapper.get('#root-case-sensitivity').setValue('Sensitive') + + await (wrapper.vm as unknown as { save: () => Promise }).save() + + expect((wrapper.vm as unknown as { showConfirm: boolean }).showConfirm).toBe(false) + expect(updateMetadata).toHaveBeenCalledWith( + 18, + expect.objectContaining({ + path: 'C:\\Library', + caseSensitivityMode: 'Sensitive', + }), + ) + expect(relocate).not.toHaveBeenCalled() + }) + + it('fails closed when the current root is missing after reload', async () => { + const pinia = createPinia() + setActivePinia(pinia) + const root = { + id: 16, + name: 'Removed Library', + path: '/removed-library', + pathSyntax: 'Unix' as const, + isDefault: false, + } + vi.spyOn(apiService, 'getRootFolders').mockResolvedValue([]) + const updateMetadata = vi.spyOn(apiService, 'updateRootFolder') + const relocate = vi.spyOn(apiService, 'changeRootFolderPath') + const wrapper = mount(RootFolderFormModal, { + props: { root }, + global: { + plugins: [pinia], + stubs: { FolderBrowserModal: true }, + }, + }) + + await (wrapper.vm as unknown as { save: () => Promise }).save() + + expect(updateMetadata).not.toHaveBeenCalled() + expect(relocate).not.toHaveBeenCalled() + expect(error).toHaveBeenCalledWith('Error', expect.stringContaining('removed')) + }) + + it('requires confirmation for a store-computed path change', async () => { + const pinia = createPinia() + setActivePinia(pinia) + const store = useRootFoldersStore() + store.folders = [ + { + id: 17, + name: 'Library', + path: '/old-library', + pathSyntax: 'Unix', + isDefault: false, + resolvedCaseSensitivity: 'Sensitive', + }, + ] + + await expect( + store.update( + 17, + { + id: 17, + name: 'Library', + path: '/new-library', + isDefault: false, + caseSensitivityMode: 'Auto', + }, + { expectedCurrentPath: '/old-library' }, + ), + ).rejects.toThrow('requires confirmation') + }) + + it('fails closed when the stored root changed while the modal was open', async () => { + const pinia = createPinia() + setActivePinia(pinia) + const store = useRootFoldersStore() + const root = { + id: 15, + name: 'Library', + path: '/old-library', + pathSyntax: 'Unix' as const, + isDefault: false, + } + store.folders = [{ ...root, path: '/newer-library' }] + const updateMetadata = vi.spyOn(apiService, 'updateRootFolder') + const relocate = vi.spyOn(apiService, 'changeRootFolderPath') + const wrapper = mount(RootFolderFormModal, { + props: { root }, + global: { + plugins: [pinia], + stubs: { FolderBrowserModal: true }, + }, + }) + + await (wrapper.vm as unknown as { save: () => Promise }).save() + + expect(updateMetadata).not.toHaveBeenCalled() + expect(relocate).not.toHaveBeenCalled() + expect(error).toHaveBeenCalledWith('Error', expect.stringContaining('changed while editing')) + }) + + it('fails closed for a case-only edit when persisted auto semantics are unknown', async () => { + const pinia = createPinia() + setActivePinia(pinia) + const store = useRootFoldersStore() + const update = vi.spyOn(store, 'update') + const wrapper = mount(RootFolderFormModal, { + props: { + root: { + id: 19, + name: 'Library', + path: 'C:\\Library', + pathSyntax: 'Windows', + isDefault: false, + caseSensitivityMode: 'Auto', + resolvedCaseSensitivity: 'Unknown', + }, + }, + global: { + plugins: [pinia], + stubs: { FolderBrowserModal: true }, + }, + }) + await wrapper.get('#root-path').setValue('C:\\library') + + await (wrapper.vm as unknown as { save: () => Promise }).save() + + expect(update).not.toHaveBeenCalled() + expect((wrapper.vm as unknown as { showConfirm: boolean }).showConfirm).toBe(true) + }) + + it.each(['Sensitive', 'Unknown'] as const)( + 'treats a case-only edit as a path change when sensitive mode resolves as %s', + async (resolvedCaseSensitivity) => { + const pinia = createPinia() + setActivePinia(pinia) + const store = useRootFoldersStore() + const update = vi.spyOn(store, 'update') + const wrapper = mount(RootFolderFormModal, { + props: { + root: { + id: 13, + name: 'Library', + path: 'C:\\Library', + pathSyntax: 'Windows', + isDefault: false, + caseSensitivityMode: 'Sensitive', + resolvedCaseSensitivity, + }, + }, + global: { + plugins: [pinia], + stubs: { + FolderBrowserModal: true, + }, + }, + }) + await wrapper.get('#root-path').setValue('C:\\library') + + await (wrapper.vm as unknown as { save: () => Promise }).save() + + expect(update).not.toHaveBeenCalled() + expect((wrapper.vm as unknown as { showConfirm: boolean }).showConfirm).toBe(true) + }, + ) + + it.each([ + [true, 'Root relocation started'], + [false, 'Root path metadata updated'], + ])('reports the path change accurately when moveFiles is %s', async (moveFiles, message) => { + const pinia = createPinia() + setActivePinia(pinia) + const store = useRootFoldersStore() + vi.spyOn(store, 'update').mockResolvedValue({ + id: 7, + name: 'Library', + path: '/new-library', + isDefault: true, + }) + const wrapper = mount(RootFolderFormModal, { + props: { + root: { + id: 7, + name: 'Library', + path: '/old-library', + isDefault: true, + }, + }, + global: { + plugins: [pinia], + stubs: { + FolderBrowserModal: true, + }, + }, + }) + await wrapper.get('#root-path').setValue('/new-library') + await ( + wrapper.vm as unknown as { confirmChange: (moveFiles: boolean) => Promise } + ).confirmChange(moveFiles) + await vi.waitFor(() => expect(success).toHaveBeenCalledWith('Success', message)) + expect(store.update).toHaveBeenCalledWith( + 7, + expect.objectContaining({ path: '/new-library' }), + expect.objectContaining({ + expectedCurrentPath: '/old-library', + pathChangeConfirmed: true, + moveFiles, + }), + ) + }) +}) diff --git a/fe/src/__tests__/bulkEditOrchestration.spec.ts b/fe/src/__tests__/bulkEditOrchestration.spec.ts new file mode 100644 index 000000000..c4f18eef1 --- /dev/null +++ b/fe/src/__tests__/bulkEditOrchestration.spec.ts @@ -0,0 +1,164 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { executeBulkEdit } from '@/utils/bulkEditOrchestration' +import type { Audiobook } from '@/types' + +const books: Record = { + 1: { + id: 1, + title: 'Book One', + authors: ['Author One'], + asin: 'B000000001', + basePath: '/library/Author One/Book One', + }, + 2: { + id: 2, + title: 'Book Two', + authors: ['Author Two'], + asin: 'B000000002', + basePath: '/library/Author Two/Book Two', + }, +} + +function createDependencies() { + return { + getAudiobook: vi.fn(async (id: number) => books[id]), + previewLibraryPath: vi.fn(async (metadata: { title: string }, destinationRoot?: string) => ({ + fullPath: `${destinationRoot}/${metadata.title}`, + relativePath: metadata.title, + root: destinationRoot, + })), + bulkUpdateAudiobooks: vi.fn(async (ids: number[]) => ({ + message: 'updated', + results: ids.map((id) => ({ id, success: true, errors: [] as string[] })), + })), + moveAudiobook: vi.fn(async (id: number) => ({ + message: 'queued', + jobId: `job-${id}`, + })), + trackQueuedJob: vi.fn(), + } +} + +describe('bulk edit orchestration', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('queues physical moves from original paths without pre-saving root metadata', async () => { + const dependencies = createDependencies() + + const outcome = await executeBulkEdit( + { + ids: [1, 2], + updates: { + monitored: true, + rootFolder: '/library-new', + moveFiles: true, + deleteEmptySource: true, + }, + destinationRoot: '/library-new', + moveFiles: true, + deleteEmptySource: true, + }, + dependencies, + ) + + expect(dependencies.bulkUpdateAudiobooks).toHaveBeenCalledWith([1, 2], { + monitored: true, + }) + expect(dependencies.previewLibraryPath).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ title: 'Book One', authors: ['Author One'] }), + '/library-new', + ) + expect( + Math.max(...dependencies.getAudiobook.mock.invocationCallOrder), + ).toBeLessThan(dependencies.bulkUpdateAudiobooks.mock.invocationCallOrder[0]) + expect( + Math.max(...dependencies.previewLibraryPath.mock.invocationCallOrder), + ).toBeLessThan(dependencies.bulkUpdateAudiobooks.mock.invocationCallOrder[0]) + expect(dependencies.bulkUpdateAudiobooks.mock.invocationCallOrder[0]).toBeLessThan( + dependencies.moveAudiobook.mock.invocationCallOrder[0], + ) + expect(dependencies.moveAudiobook).toHaveBeenNthCalledWith( + 1, + 1, + '/library-new/Book One', + { + sourcePath: '/library/Author One/Book One', + moveFiles: true, + deleteEmptySource: true, + }, + ) + expect(dependencies.trackQueuedJob).toHaveBeenCalledWith({ + jobId: 'job-1', + audiobookId: 1, + target: '/library-new/Book One', + }) + expect(outcome.results).toEqual([ + { id: 1, success: true, errors: [] }, + { id: 2, success: true, errors: [] }, + ]) + }) + + it('surfaces a per-item physical enqueue failure instead of reporting success', async () => { + const dependencies = createDependencies() + dependencies.moveAudiobook.mockImplementation(async (id: number) => { + if (id === 2) throw new Error('queue unavailable') + return { message: 'queued', jobId: `job-${id}` } + }) + + const outcome = await executeBulkEdit( + { + ids: [1, 2], + updates: { rootFolder: '/library-new', moveFiles: true }, + destinationRoot: '/library-new', + moveFiles: true, + deleteEmptySource: false, + }, + dependencies, + ) + + expect(outcome.results).toEqual([ + { id: 1, success: true, errors: [] }, + { id: 2, success: false, errors: ['queue unavailable'] }, + ]) + expect(dependencies.trackQueuedJob).toHaveBeenCalledTimes(1) + expect(dependencies.trackQueuedJob).not.toHaveBeenCalledWith( + expect.objectContaining({ audiobookId: 2 }), + ) + }) + + it('keeps metadata-only root changes in the bulk request and does not queue moves', async () => { + const dependencies = createDependencies() + + await executeBulkEdit( + { + ids: [1], + updates: { monitored: false, rootFolder: '/library-new' }, + destinationRoot: '/library-new', + moveFiles: false, + deleteEmptySource: false, + }, + dependencies, + ) + + expect(dependencies.bulkUpdateAudiobooks).toHaveBeenCalledWith([1], { + monitored: false, + rootFolder: '/library-new', + }) + expect(dependencies.getAudiobook).not.toHaveBeenCalled() + expect(dependencies.previewLibraryPath).not.toHaveBeenCalled() + expect(dependencies.moveAudiobook).not.toHaveBeenCalled() + expect(dependencies.trackQueuedJob).not.toHaveBeenCalled() + }) +}) diff --git a/fe/src/__tests__/debug_AddNew.spec.ts b/fe/src/__tests__/debug_AddNew.spec.ts deleted file mode 100644 index 932f46770..000000000 --- a/fe/src/__tests__/debug_AddNew.spec.ts +++ /dev/null @@ -1,20 +0,0 @@ -/* - * Listenarr - Audiobook Management System - * Copyright (C) 2024-2026 Listenarr Contributors - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published - * by the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ -import { describe } from 'vitest' - -describe.todo('debug AddNew placeholder') diff --git a/fe/src/__tests__/moveJobs.store.spec.ts b/fe/src/__tests__/moveJobs.store.spec.ts new file mode 100644 index 000000000..6cd8035ee --- /dev/null +++ b/fe/src/__tests__/moveJobs.store.spec.ts @@ -0,0 +1,237 @@ +/* + * Listenarr - Audiobook Management System + * Copyright (C) 2024-2026 Listenarr Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createPinia, setActivePinia } from 'pinia' + +type MoveJobUpdate = { + jobId?: string + audiobookId?: number + status?: string + target?: string + error?: string +} + +const toastMocks = vi.hoisted(() => ({ + info: vi.fn(), + success: vi.fn(), + error: vi.fn(), +})) + +const apiMocks = vi.hoisted(() => ({ + getMoveJobStatus: vi.fn(), +})) + +const signalRMocks = vi.hoisted(() => { + const state = { + callback: null as ((job: MoveJobUpdate) => void) | null, + unsubscribe: vi.fn(), + onMoveJobUpdate: vi.fn(), + } + state.onMoveJobUpdate.mockImplementation((callback: (job: MoveJobUpdate) => void) => { + state.callback = callback + return state.unsubscribe + }) + return state +}) + +vi.mock('@/services/api', () => ({ + apiService: { + getMoveJobStatus: apiMocks.getMoveJobStatus, + }, +})) + +vi.mock('@/services/toastService', () => ({ + useToast: () => toastMocks, +})) + +vi.mock('@/services/signalr', () => ({ + signalRService: { + onMoveJobUpdate: signalRMocks.onMoveJobUpdate, + }, +})) + +import { useMoveJobsStore } from '@/stores/moveJobs' + +describe('move jobs store', () => { + beforeEach(() => { + vi.clearAllMocks() + setActivePinia(createPinia()) + signalRMocks.callback = null + apiMocks.getMoveJobStatus.mockImplementation(() => new Promise(() => {})) + signalRMocks.onMoveJobUpdate.mockImplementation((callback: (job: MoveJobUpdate) => void) => { + signalRMocks.callback = callback + return signalRMocks.unsubscribe + }) + }) + + it('starts SignalR subscription idempotently', () => { + const store = useMoveJobsStore() + + store.start() + store.start() + + expect(signalRMocks.onMoveJobUpdate).toHaveBeenCalledTimes(1) + + store.stop() + expect(signalRMocks.unsubscribe).toHaveBeenCalledTimes(1) + }) + + it('tracks queued move jobs and subscribes on first track', () => { + const store = useMoveJobsStore() + + store.trackQueuedJob({ + jobId: 'JOB-1', + audiobookId: 42, + target: '/library/book', + }) + + expect(signalRMocks.onMoveJobUpdate).toHaveBeenCalledTimes(1) + expect(store.trackedById['job-1']).toEqual({ + jobId: 'JOB-1', + audiobookId: 42, + status: 'Queued', + target: '/library/book', + }) + }) + + it('shows one in-progress toast when a tracked job starts running', () => { + const store = useMoveJobsStore() + store.trackQueuedJob({ jobId: 'job-1', target: '/library/book' }) + + signalRMocks.callback?.({ jobId: 'job-1', status: 'Running', target: '/library/book' }) + signalRMocks.callback?.({ jobId: 'job-1', status: 'Running', target: '/library/book' }) + + expect(toastMocks.info).toHaveBeenCalledTimes(1) + expect(toastMocks.info).toHaveBeenCalledWith( + 'Move in progress', + 'Moving files to /library/book', + ) + expect(store.trackedById['job-1']?.status).toBe('Running') + }) + + it('shows success toast and clears tracked job on completion', () => { + const store = useMoveJobsStore() + store.trackQueuedJob({ jobId: 'job-1', target: '/library/book' }) + + signalRMocks.callback?.({ jobId: 'job-1', status: 'Completed', target: '/library/book' }) + + expect(toastMocks.success).toHaveBeenCalledWith( + 'Move completed', + 'Files moved to /library/book', + ) + expect(store.trackedById['job-1']).toBeUndefined() + }) + + it('shows attention toast and clears tracked job on NeedsAttention', () => { + const store = useMoveJobsStore() + store.trackQueuedJob({ jobId: 'job-1', target: '/library/book' }) + + signalRMocks.callback?.({ + jobId: 'job-1', + status: 'NeedsAttention', + target: '/library/book', + error: 'Manual review required', + }) + + expect(toastMocks.error).toHaveBeenCalledWith('Move needs attention', 'Manual review required') + expect(store.trackedById['job-1']).toBeUndefined() + }) + + it('reconciles a job that completed before tracking began', async () => { + apiMocks.getMoveJobStatus.mockResolvedValue({ + jobId: 'job-1', + status: 'Completed', + target: '/library/book', + }) + const store = useMoveJobsStore() + + store.trackQueuedJob({ jobId: 'job-1', target: '/library/book' }) + + await vi.waitFor(() => expect(store.trackedById['job-1']).toBeUndefined()) + expect(toastMocks.success).toHaveBeenCalledWith( + 'Move completed', + 'Files moved to /library/book', + ) + }) + + it('does not recreate a terminal job when a stale status response arrives later', async () => { + let resolveStatus: + | ((value: { jobId: string; status: string; target: string }) => void) + | undefined + apiMocks.getMoveJobStatus.mockImplementation( + () => + new Promise((resolve) => { + resolveStatus = resolve + }), + ) + const store = useMoveJobsStore() + store.trackQueuedJob({ jobId: 'job-1', target: '/library/book' }) + + signalRMocks.callback?.({ jobId: 'job-1', status: 'Completed', target: '/library/book' }) + resolveStatus?.({ jobId: 'job-1', status: 'Queued', target: '/library/book' }) + await Promise.resolve() + + expect(store.trackedById['job-1']).toBeUndefined() + expect(toastMocks.success).toHaveBeenCalledTimes(1) + }) + + it('does not regress a running job when a stale queued status response arrives', async () => { + let resolveStatus: + | ((value: { jobId: string; status: string; target: string }) => void) + | undefined + apiMocks.getMoveJobStatus.mockImplementation( + () => + new Promise((resolve) => { + resolveStatus = resolve + }), + ) + const store = useMoveJobsStore() + store.trackQueuedJob({ jobId: 'job-1', target: '/library/book' }) + + signalRMocks.callback?.({ jobId: 'job-1', status: 'Running', target: '/library/book' }) + resolveStatus?.({ jobId: 'job-1', status: 'Queued', target: '/library/book' }) + await Promise.resolve() + + expect(store.trackedById['job-1']?.status).toBe('Running') + expect(toastMocks.info).toHaveBeenCalledTimes(1) + }) + + it('keeps tracking when status reconciliation fails', async () => { + apiMocks.getMoveJobStatus.mockRejectedValue(new Error('offline')) + const store = useMoveJobsStore() + + store.trackQueuedJob({ jobId: 'job-1', target: '/library/book' }) + + await vi.waitFor(() => expect(apiMocks.getMoveJobStatus).toHaveBeenCalledWith('job-1')) + expect(store.trackedById['job-1']?.status).toBe('Queued') + expect(toastMocks.error).not.toHaveBeenCalled() + }) + + it('shows terminal error toast and clears tracked job on Superseded', () => { + const store = useMoveJobsStore() + store.trackQueuedJob({ jobId: 'job-1', target: '/library/book' }) + + signalRMocks.callback?.({ jobId: 'job-1', status: 'Superseded', target: '/library/book' }) + + expect(toastMocks.error).toHaveBeenCalledWith( + 'Move failed', + 'Move job did not complete. Check the move queue.', + ) + expect(store.trackedById['job-1']).toBeUndefined() + }) +}) diff --git a/fe/src/__tests__/services/apiErrors.spec.ts b/fe/src/__tests__/services/apiErrors.spec.ts new file mode 100644 index 000000000..83eadaf22 --- /dev/null +++ b/fe/src/__tests__/services/apiErrors.spec.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest' +import { getApiValidationError } from '@/services/apiErrors' + +describe('getApiValidationError', () => { + it('returns a matching structured field error and preserves the resolved destination', () => { + const error = Object.assign(new Error('API error'), { + status: 400, + body: JSON.stringify({ + code: 'destination_path_outside_roots', + field: 'destinationPath', + message: 'DestinationPath must be inside a configured root folder or output path', + resolvedDestination: '/outside/Author/Title', + }), + }) + + expect(getApiValidationError(error, 'destinationPath')).toEqual({ + code: 'destination_path_outside_roots', + field: 'destinationPath', + message: 'DestinationPath must be inside a configured root folder or output path', + resolvedDestination: '/outside/Author/Title', + }) + }) + + it('does not return an error for another field', () => { + const error = Object.assign(new Error('API error'), { + body: JSON.stringify({ + field: 'title', + message: 'Title is invalid', + }), + }) + + expect(getApiValidationError(error, 'destinationPath')).toBeNull() + }) + + it.each(['not-json', '{}', '{"message":""}'])( + 'fails closed for an unusable response body: %s', + (body) => { + expect(getApiValidationError(Object.assign(new Error('API error'), { body }))).toBeNull() + }, + ) +}) diff --git a/fe/src/__tests__/test-setup.ts b/fe/src/__tests__/test-setup.ts index f37251826..1f961606f 100644 --- a/fe/src/__tests__/test-setup.ts +++ b/fe/src/__tests__/test-setup.ts @@ -164,8 +164,10 @@ vi.mock('@/services/api', () => { executeRename: vi.fn(async () => []), getQualityProfiles: vi.fn(async () => []), getApiConfigurations: vi.fn(async () => []), - // add getRootFolders to apiService so tests that spy on apiService.getRootFolders work + // Root-folder mutation methods used by store/component integration tests. getRootFolders: vi.fn(async () => []), + updateRootFolder: vi.fn(async (_id: number, payload: unknown) => payload), + changeRootFolderPath: vi.fn(async () => ({})), // add checkVolume to apiService so components that call `apiService.checkVolume` in // unit tests have a sensible default value that matches the real API signature. diff --git a/fe/src/__tests__/utils/path.spec.ts b/fe/src/__tests__/utils/path.spec.ts index 88ece48b9..63dcd583d 100644 --- a/fe/src/__tests__/utils/path.spec.ts +++ b/fe/src/__tests__/utils/path.spec.ts @@ -21,7 +21,24 @@ import { trimTrailingSlash, normalizeForCompare, isAbsolutePath, + hasRelativePathSegment, + hasParentTraversalSegment, + hasEmptyMiddlePathSegment, + hasControlCharacter, + hasOuterWhitespace, + hasPathSegmentOuterWhitespace, + hasWindowsDriveRelativePath, + hasIncompleteWindowsUncAuthority, + hasWindowsTrailingSpaceOrPeriodSegment, + hasWindowsInvalidCharacter, + pathsOverlap, + pathsEqual, + pathIsInside, + hasWindowsReservedDeviceSegment, + validateLibraryDestinationPath, stripRootPrefix, + detectPathKind, + joinPaths, } from '@/utils/path' describe('path utils', () => { @@ -30,41 +47,271 @@ describe('path utils', () => { expect(toForward(null)).toBe('') }) - it('trimTrailingSlash removes trailing slashes', () => { + it('trimTrailingSlash removes trailing slashes without collapsing drive roots', () => { expect(trimTrailingSlash('C:/path/')).toBe('C:/path') expect(trimTrailingSlash('C:\\path\\')).toBe('C:\\path') expect(trimTrailingSlash('no-slash')).toBe('no-slash') + expect(trimTrailingSlash('/')).toBe('/') + expect(trimTrailingSlash('C:\\')).toBe('C:\\') + expect(trimTrailingSlash('C:\\\\')).toBe('C:\\') + expect(trimTrailingSlash('C:////')).toBe('C:/') }) it('normalizeForCompare lowercases and trims', () => { expect(normalizeForCompare('C:\\Temp\\Dir\\')).toBe('c:/temp/dir') }) - it('isAbsolutePath detects absolute paths', () => { + it('isAbsolutePath respects explicit filesystem context', () => { expect(isAbsolutePath('C:\\some\\path')).toBe(true) expect(isAbsolutePath('/unix/path')).toBe(true) + expect(isAbsolutePath('\\library', 'windows')).toBe(false) + expect(isAbsolutePath('\\', 'windows')).toBe(true) + expect(isAbsolutePath('\\library', 'unix')).toBe(false) + expect(isAbsolutePath('C:\\library', 'unix')).toBe(false) + expect(isAbsolutePath('/library', 'windows')).toBe(false) + expect(isAbsolutePath('/', 'windows')).toBe(true) expect(isAbsolutePath('relative/path')).toBe(false) }) - it('stripRootPrefix removes root prefix when present', () => { + it('classifies and rejects Windows drive-relative paths', () => { + expect(detectPathKind('C:')).toBe('windows') + expect(detectPathKind('C:relative')).toBe('windows') + expect(hasWindowsDriveRelativePath('C:')).toBe(true) + expect(hasWindowsDriveRelativePath('C:relative')).toBe(true) + expect(hasWindowsDriveRelativePath('C:\\')).toBe(false) + expect(validateLibraryDestinationPath('C:')).toContain('separator after the drive letter') + expect(validateLibraryDestinationPath('C:relative')).toContain( + 'separator after the drive letter', + ) + expect(validateLibraryDestinationPath('C:\\')).toBe(null) + expect(validateLibraryDestinationPath('C:/')).toBe(null) + expect(validateLibraryDestinationPath('Books', { requireAbsolute: true })).toContain( + 'absolute directory path', + ) + expect( + validateLibraryDestinationPath('\\library', { + pathKind: 'unix', + requireAbsolute: true, + }), + ).toContain('absolute directory path') + expect(validateLibraryDestinationPath('Books')).toBe(null) + expect(normalizeForCompare('C:\\\\', 'windows')).toBe('c:/') + expect(stripRootPrefix('C:\\', 'C:\\Books', 'Insensitive', 'windows')).toBe('Books') + expect(joinPaths('C:\\\\', 'Books', 'windows')).toBe('C:\\Books') + }) + + it('classifies absolute Unix paths with backslashes as Unix paths', () => { + expect(detectPathKind('/books/Author\\Name')).toBe('unix') + expect(normalizeForCompare('/books/Author\\Name')).toBe('/books/Author\\Name') + }) + + it('requires context for double-slash absolute paths', () => { + expect(detectPathKind('//server/share/Books')).toBe('unknown') + expect(detectPathKind('//server/share/Books', 'windows')).toBe('windows') + expect(detectPathKind('//server/share/Books', 'unix')).toBe('unix') + expect(hasEmptyMiddlePathSegment('//server/share/Books')).toBe(false) + expect(hasEmptyMiddlePathSegment('//server/share/Books', 'windows')).toBe(false) + expect(hasEmptyMiddlePathSegment('//server/share/Books', 'unix')).toBe(false) + expect(validateLibraryDestinationPath('//server/share/Books/Author')).toBe(null) + expect( + validateLibraryDestinationPath('//server/share/Books/CON', { pathKind: 'windows' }), + ).toContain('reserved Windows') + expect(validateLibraryDestinationPath('//server/share/Books/CON', { pathKind: 'unix' })).toBe( + null, + ) + }) + + it('detects exact relative path segments without blocking periods in names', () => { + expect(hasRelativePathSegment('D:\\Books\\Title\\.')).toBe(true) + expect(hasRelativePathSegment('D:\\Books\\Title\\..')).toBe(true) + expect(hasRelativePathSegment('/books/./title')).toBe(true) + expect(hasRelativePathSegment('/books/../title')).toBe(true) + expect(hasRelativePathSegment('/books/Dr. Seuss')).toBe(false) + expect(hasRelativePathSegment('/books/.metadata')).toBe(false) + expect(hasRelativePathSegment('/books/title...')).toBe(false) + }) + + it('hasParentTraversalSegment detects parent directory traversal', () => { + expect(hasParentTraversalSegment('D:\\Books\\Title\\..')).toBe(true) + expect(hasParentTraversalSegment('/books/title/../other')).toBe(true) + expect(hasParentTraversalSegment('/books/title..')).toBe(false) + expect(hasParentTraversalSegment('/books/.../title')).toBe(false) + expect(hasParentTraversalSegment(null)).toBe(false) + }) + + it('detects empty middle path segments without rejecting roots', () => { + expect(hasEmptyMiddlePathSegment('D:\\Books\\\\Title')).toBe(true) + expect(hasEmptyMiddlePathSegment('/books//title')).toBe(true) + expect(hasEmptyMiddlePathSegment('D:\\Books\\Title')).toBe(false) + expect(hasEmptyMiddlePathSegment('/books/title')).toBe(false) + expect(hasEmptyMiddlePathSegment('D:\\')).toBe(false) + expect(hasEmptyMiddlePathSegment('\\\\server\\share\\Audiobooks')).toBe(false) + expect(hasEmptyMiddlePathSegment('\\\\server\\share\\\\Audiobooks')).toBe(true) + }) + + it('validates Windows UNC authority structure', () => { + expect(hasIncompleteWindowsUncAuthority('\\\\server', 'windows')).toBe(true) + expect(hasIncompleteWindowsUncAuthority('\\\\server\\', 'windows')).toBe(true) + expect(hasIncompleteWindowsUncAuthority('\\\\server\\share', 'windows')).toBe(false) + expect(hasIncompleteWindowsUncAuthority('//server/share', 'windows')).toBe(false) + expect(hasIncompleteWindowsUncAuthority('//server', 'unix')).toBe(false) + + expect( + validateLibraryDestinationPath('\\\\server', { + pathKind: 'windows', + requireAbsolute: true, + }), + ).toContain('server and share') + expect( + validateLibraryDestinationPath('\\\\server\\share', { + pathKind: 'windows', + requireAbsolute: true, + }), + ).toBe(null) + expect( + validateLibraryDestinationPath('\\\\server\\NUL\\Books', { + pathKind: 'windows', + }), + ).toContain('reserved Windows') + expect( + validateLibraryDestinationPath('\\\\NUL\\share\\Books', { + pathKind: 'windows', + }), + ).toContain('reserved Windows') + expect(validateLibraryDestinationPath('//server', { pathKind: 'unix' })).toBe(null) + }) + + it('detects control characters and segment whitespace', () => { + expect(hasControlCharacter('D:\\Books\\Title\n')).toBe(true) + expect(hasControlCharacter('D:\\Books\\Title')).toBe(false) + expect(hasOuterWhitespace(' D:\\Books\\Title')).toBe(true) + expect(hasOuterWhitespace('D:\\Books\\Title ')).toBe(true) + expect(hasOuterWhitespace('D:\\Listenarr Test\\Title')).toBe(false) + expect(hasPathSegmentOuterWhitespace('D:\\Books\\test ')).toBe(true) + expect(hasPathSegmentOuterWhitespace('D:\\Books\\ test')).toBe(true) + expect(hasPathSegmentOuterWhitespace('D:\\Listenarr Test\\Title')).toBe(false) + }) + + it('detects Windows-only trailing space or period segments', () => { + expect(hasWindowsTrailingSpaceOrPeriodSegment('D:\\Books\\test ')).toBe(true) + expect(hasWindowsTrailingSpaceOrPeriodSegment('D:\\Books\\test.')).toBe(true) + expect(hasWindowsTrailingSpaceOrPeriodSegment('D:\\Books\\ test')).toBe(false) + expect(hasWindowsTrailingSpaceOrPeriodSegment('/books/test ')).toBe(false) + expect(hasWindowsTrailingSpaceOrPeriodSegment('/books/ test ')).toBe(false) + }) + + it('detects Windows invalid characters and reserved device names', () => { + expect(hasWindowsInvalidCharacter('D:\\Books\\Bad|Folder')).toBe(true) + expect(hasWindowsInvalidCharacter('D:\\Books\\Bad:Folder')).toBe(true) + expect(hasWindowsInvalidCharacter('D:\\Books\\Good Folder')).toBe(false) + expect(hasWindowsReservedDeviceSegment('D:\\Books\\CON')).toBe(true) + expect(hasWindowsReservedDeviceSegment('D:\\Books\\NUL.txt')).toBe(true) + expect(hasWindowsReservedDeviceSegment('D:\\Books\\COM1.folder')).toBe(true) + expect(hasWindowsReservedDeviceSegment('\\\\server\\NUL\\Books', 'windows')).toBe(true) + expect(hasWindowsReservedDeviceSegment('\\\\NUL\\share\\Books', 'windows')).toBe(true) + expect(hasWindowsReservedDeviceSegment('D:\\Books\\Concert')).toBe(false) + }) + + it('detects overlapping source and destination paths', () => { + expect(pathsOverlap('D:\\Books\\Title\\Child', 'D:\\Books\\Title', 'windows')).toBe(true) + expect(pathsOverlap('D:\\Books\\Title', 'D:\\Books\\Title\\Child', 'windows')).toBe(true) + expect(pathsOverlap('D:\\Books\\Title2', 'D:\\Books\\Title', 'windows')).toBe(false) + expect(pathsOverlap('/books/title/child', '/books/title', 'unix')).toBe(true) + expect(pathsOverlap('/books/title2', '/books/title', 'unix')).toBe(false) + expect(pathsOverlap('/Books/title', '/books', 'unix')).toBe(false) + expect(pathIsInside('/Author/Title', '/', 'unix')).toBe(true) + }) + + it('uses server-provided case sensitivity instead of path shape', () => { + expect(pathsEqual('/Books/Title', '/books/title', 'unix', 'Insensitive')).toBe(true) + expect(pathsEqual('C:\\Books\\Title', 'c:\\books\\title', 'windows', 'Sensitive')).toBe(false) + expect(pathIsInside('/Books/Title', '/books', 'unix', 'Insensitive')).toBe(true) + }) + + it('validates library destination paths while allowing platform-valid whitespace', () => { + expect(validateLibraryDestinationPath('D:\\Books\\Title\\.')).toContain( + 'current-directory path segments', + ) + expect(validateLibraryDestinationPath('/books/title/.')).toContain( + 'current-directory path segments', + ) + expect(validateLibraryDestinationPath('D:\\Books\\Title\\..')).toContain( + 'Path traversal is not allowed', + ) + expect(validateLibraryDestinationPath('/books/title/..')).toContain( + 'Path traversal is not allowed', + ) + expect(validateLibraryDestinationPath('D:\\Books\\\\Title')).toContain('empty path segments') + expect(validateLibraryDestinationPath('D:\\Books\\Bad*Folder')).toContain('invalid on Windows') + expect(validateLibraryDestinationPath('D:\\Books\\CON.txt')).toContain('reserved Windows') + expect(validateLibraryDestinationPath('D:\\Books\\test ')).toContain( + 'cannot end with a space or period', + ) + expect(validateLibraryDestinationPath('D:\\Books\\test.')).toContain( + 'cannot end with a space or period', + ) + expect(validateLibraryDestinationPath('D:\\Books\\ test')).toBe(null) + expect(validateLibraryDestinationPath('/books/ test /')).toBe(null) + expect(validateLibraryDestinationPath('D:\\Books\\Dr. Seuss')).toBe(null) + expect(validateLibraryDestinationPath('D:\\Books\\.metadata')).toBe(null) + expect(validateLibraryDestinationPath('D:\\Books\\Title...')).toContain( + 'cannot end with a space or period', + ) + expect(validateLibraryDestinationPath('/books/Title...')).toBe(null) + expect( + validateLibraryDestinationPath('D:\\Books\\Title\\Child', { + pathKind: 'windows', + sourcePath: 'D:\\Books\\Title', + }), + ).toBe(null) + expect( + validateLibraryDestinationPath('/books/title/child', { + pathKind: 'unix', + sourcePath: '/books/title', + }), + ).toBe(null) + expect( + validateLibraryDestinationPath('D:\\Books', { + pathKind: 'windows', + sourcePath: 'D:\\Books\\Title', + }), + ).toBe(null) + }) + + it('stripRootPrefix removes only a complete root boundary', () => { const root = 'C:\\temp\\Isaac Asimov\\Foundation' const full = 'C:\\temp\\Isaac Asimov\\Foundation\\Prelude to Foundation' - const rel = stripRootPrefix(root, full) - expect(rel).toBe('Prelude to Foundation') + expect(stripRootPrefix(root, full)).toBe('Prelude to Foundation') + expect(stripRootPrefix(root, root)).toBe('') - // preserves backslash style when root uses backslashes - const root2 = 'C:/temp/Isaac Asimov/Foundation' - const full2 = 'C:/temp/Isaac Asimov/Foundation/Prelude to Foundation' - const rel2 = stripRootPrefix(root2, full2) - expect(rel2).toBe('Prelude to Foundation') + const forwardRoot = 'C:/temp/Isaac Asimov/Foundation' + const forwardFull = 'C:/temp/Isaac Asimov/Foundation/Prelude to Foundation' + expect(stripRootPrefix(forwardRoot, forwardFull)).toBe('Prelude to Foundation') - // returns null when no match expect(stripRootPrefix('C:/root/other', full)).toBe(null) - - // matches using last segments - const root3 = 'C:/temp/Isaac Asimov/Foundation/Extra' - const full3 = 'C:/some/prefix/isaac asimov/foundation/Prelude' - const rel3 = stripRootPrefix(root3, full3) - expect(rel3).toBe('Prelude') + expect(stripRootPrefix('C:/root/books', 'C:/root/bookshelf/Title')).toBe(null) + expect(stripRootPrefix('C:/root/books/Extra', 'C:/other/root/bookshelf/Title')).toBe(null) + expect( + stripRootPrefix( + 'C:/temp/Isaac Asimov/Foundation/Extra', + 'C:/some/prefix/isaac asimov/foundation/Prelude', + ), + ).toBe(null) + expect(stripRootPrefix('C:/Books', 'D:/Books/Title')).toBe(null) + expect(stripRootPrefix('C:/Books', 'c:/books/Title', 'Sensitive')).toBe(null) + expect(stripRootPrefix('C:/Books', 'c:/books/Title', 'Insensitive')).toBe('Title') + expect(stripRootPrefix('\\\\server\\share\\Books', '//server/share/Books/Title')).toBe('Title') + expect(stripRootPrefix('\\\\server\\share\\Books', '//server/other/Books/Title')).toBe(null) + expect( + stripRootPrefix( + '//server/share/Books', + '//server/share/Books/Title', + 'Insensitive', + 'windows', + ), + ).toBe('Title') + expect(stripRootPrefix('//srv/library', '//srv/library/Title', 'Sensitive', 'unix')).toBe( + 'Title', + ) }) }) diff --git a/fe/src/components/domain/audiobook/AddLibraryModal.vue b/fe/src/components/domain/audiobook/AddLibraryModal.vue index 047440642..4cab16a3f 100644 --- a/fe/src/components/domain/audiobook/AddLibraryModal.vue +++ b/fe/src/components/domain/audiobook/AddLibraryModal.vue @@ -400,12 +400,24 @@ /> - Enter an absolute path where files will be stored + Enter an absolute destination within a configured root folder or output path. Select a named root (or custom path) and edit the path relative to it on the right. +
+ Effective destination: + {{ estimatedFullPath }} +
+
+ + {{ destinationPathValidationError }} +
@@ -438,7 +450,11 @@ Cancel - @@ -520,10 +521,23 @@ > Choose a root folder from the dropdown, or select - "Custom path" to specify any location. The right field is for - organizing within the selected root. + "Custom path" to enter an absolute destination within a configured root + folder or output path. The right field is for organizing within the selected + root.

+
+ Effective destination: + {{ editDestinationPath }} +
+
+ + {{ destinationPathValidationError }} +
@@ -723,20 +737,12 @@ > Close -
- - Move Job: {{ moveJob.jobId }} — {{ moveJob.status }} - -
- Target: {{ moveJob.target }} -
-