Add MonitorAlbum component tests: 21 cases covering all flow diagrams (bufconn + testcontainers + hand-rolled mocks)

This commit is contained in:
Alexander
2026-05-09 21:31:09 +02:00
parent 6f31698006
commit 31ec3f9826
23 changed files with 2166 additions and 4 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

@@ -0,0 +1,39 @@
@startuml MonitorAlbum - Already Owned (Early Return)
skinparam sequenceMessageAlign center
skinparam responseMessageBelowArrow true
title MonitorAlbum: Album Already Owned in Requested Quality
actor Client
participant "MusicAgregatorService" as Service
participant "MetadataService" as Metadata
database "metadata-agregator\n(gRPC)" as MetaGRPC
database "PostgreSQL" as DB
Client -> Service: MonitorAlbum(album_id, quality=LOSSLESS)
Service -> Metadata: GetAlbum(album_id)
Metadata -> MetaGRPC: GetAlbum(id)
MetaGRPC --> Metadata: Album
Metadata -> DB: albums.GetByExternalID()
DB --> Metadata: found (already persisted)
note right: Album exists from\nprevious MonitorAlbum\nor GetArtists discovery
Metadata --> Service: Album
Service -> DB: albums.GetByExternalID()
DB --> Service: dbAlbum
Service -> DB: albums.SetMonitorState(id, monitored)
note right #lightgreen: Always set monitored\nregardless of outcome
Service -> DB: downloads.HasAlbumInQuality(id, LOSSLESS, "16-44")
DB --> Service: true
note right #lightgreen: Found existing download\nwith state IN\n('completed', 'seeding')
Service -> Service: buildMonitorAlbumResponse()
Service -> DB: downloads.GetByAlbumID()
DB --> Service: download (completed, FLAC, /downloads)
Service -> DB: artists.GetByExternalID()
DB --> Service: artist
Service --> Client: MonitorAlbumResponse
note right #lightgreen: album: monitored, download info\nartist: monitored\nrelease: nil (no new search)\n\nNo indexer search.\nNo torrent client interaction.\nFast response.
@enduml
Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

@@ -0,0 +1,74 @@
@startuml MonitorAlbum - Indexer & Parse Failures
skinparam sequenceMessageAlign center
skinparam responseMessageBelowArrow true
title MonitorAlbum Error: Indexer Search & Parse Failures
actor Client
participant "MusicAgregatorService" as Service
participant "IndexerService\n(Jackett)" as Indexer
participant "MagnetResolver" as Magnet
== Case 1: Indexer search fails (Jackett down / timeout) ==
Client -> Service: MonitorAlbum(album_id, quality)
note right: metadata fetch + persist succeeded
Service -> Indexer: Search("Artist Album", tracker)
Indexer --> Service: error (connection refused / timeout)
Service --> Client: error
note right #salmon: Full stop after metadata.\nAlbum is persisted & monitored\nbut no torrent found.
== Case 2: Indexer returns zero results ==
Client -> Service: MonitorAlbum(album_id, quality)
Service -> Indexer: Search("Artist Album", tracker)
Indexer --> Service: SearchResponse (0 items)
Service -> Service: parseSearchResults → empty
Service -> Service: filterByQuality → empty
Service --> Client: MonitorAlbumResponse
note right #orange: Partial response.\nalbum + artist returned.\nrelease: nil.\nNo torrent added.
== Case 3: All results have no seeders ==
Client -> Service: MonitorAlbum(album_id, quality)
Service -> Indexer: Search(...)
Indexer --> Service: SearchResponse (5 items, all seeders=0)
loop for each item
Service -> Service: item.Seeders == 0 → skip
note right: Logged as warning per item
end
Service -> Service: parsed = empty
Service -> Service: filterByQuality → empty
Service --> Client: MonitorAlbumResponse (no release)
== Case 4: All magnet resolves fail ==
Client -> Service: MonitorAlbum(album_id, quality)
Service -> Indexer: Search(...)
Indexer --> Service: SearchResponse (3 items)
loop for each item
Service -> Magnet: Resolve(magnet_uri)
Magnet --> Service: error (timeout / no active peers)
Service -> Service: fallback to title parse
note right: Release parsed from title only.\nFormat may be "unknown".\nNo torrent data (nil).
end
Service -> Service: filterByQuality(parsed, LOSSLESS)
note right #orange: Title-parsed releases may have\nformat=unknown → not lossless\n→ filtered out
Service --> Client: MonitorAlbumResponse (no release)
== Case 5: No results match quality filter ==
Client -> Service: MonitorAlbum(album_id, quality=LOSSLESS)
Service -> Indexer: Search(...)
Indexer --> Service: SearchResponse (3 items)
Service -> Service: parseSearchResults → 3 items (all MP3)
Service -> Service: filterByQuality(LOSSLESS) → empty
note right: All releases are lossy.\nQuality filter rejects all.
Service --> Client: MonitorAlbumResponse
note right #orange: album + artist returned.\nrelease: nil.\n"no releases match quality filter"
@enduml
Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

@@ -0,0 +1,58 @@
@startuml MonitorAlbum - Metadata Fetch Failures
skinparam sequenceMessageAlign center
skinparam responseMessageBelowArrow true
title MonitorAlbum Error: Metadata Fetch Failures
actor Client
participant "MusicAgregatorService" as Service
participant "MetadataService" as Metadata
database "metadata-agregator\n(gRPC)" as MetaGRPC
database "PostgreSQL" as DB
== Case 1: metadata-agregator unreachable ==
Client -> Service: MonitorAlbum(album_id, quality)
Service -> Metadata: GetAlbum(album_id)
Metadata -> MetaGRPC: GetAlbum(id)
MetaGRPC --> Metadata: gRPC error (Unavailable)
Metadata --> Service: error: "fetching album: ..."
Service --> Client: error (Unavailable)
note right: Full stop.\nNo DB writes occur.\nNo indexer search.
== Case 2: Album not found in metadata ==
Client -> Service: MonitorAlbum(album_id, quality)
Service -> Metadata: GetAlbum(album_id)
Metadata -> MetaGRPC: GetAlbum(id)
MetaGRPC --> Metadata: gRPC error (NotFound)
Metadata --> Service: error: "fetching album: ..."
Service --> Client: error (NotFound)
note right: Full stop.\nInvalid album_id.\nNo side effects.
== Case 3: Album found, but artist persist fails ==
Client -> Service: MonitorAlbum(album_id, quality)
Service -> Metadata: GetAlbum(album_id)
Metadata -> MetaGRPC: GetAlbum(id)
MetaGRPC --> Metadata: Album
Metadata -> DB: albums.GetByExternalID()
DB --> Metadata: not found
Metadata -> DB: artists.Create()
DB --> Metadata: error (e.g. connection lost)
note right #salmon: Artist persist fails.\nLogged as warning.\nFlow continues.
Metadata -> DB: albums.Create()
note right #salmon: artistID is empty\n→ album persist skipped\n(no artist reference)
Metadata --> Service: Album (metadata only)
Service -> DB: albums.GetByExternalID()
DB --> Service: not found (album never persisted)
note right #salmon: dbAlbum is nil.\nMonitor state not set.\nOwnership check skipped.
Service -> Service: continues to indexer search...
note right: Flow proceeds but\ndownload persistence\nwill be skipped later\n(dbAlbum == nil)
@enduml
Binary file not shown.

After

Width:  |  Height:  |  Size: 180 KiB

@@ -0,0 +1,90 @@
@startuml MonitorAlbum - Async Poll Worker Failures
skinparam sequenceMessageAlign center
skinparam responseMessageBelowArrow true
title MonitorAlbum: Async Download Poll Worker Failures
participant "River Queue" as River
participant "PollDownloadWorker" as Worker
participant "TorrentClient\n(qBittorrent)" as QBit
database "PostgreSQL" as DB
note over Worker: These occur asynchronously\nafter MonitorAlbum returns.\nClient already received response.
== Case 1: qBittorrent unreachable during poll ==
River -> Worker: Work(PollDownloadArgs)
Worker -> QBit: Find(hash)
QBit --> Worker: error (connection refused)
note right #orange: Logged as error.\nJob rescheduled.
Worker -> River: Insert(PollDownloadArgs)
note right: Reschedule after check_interval (30s).\nRetries indefinitely until\nqBit becomes available.
== Case 2: Torrent disappeared from qBittorrent ==
River -> Worker: Work(PollDownloadArgs)
Worker -> QBit: Find(hash)
QBit --> Worker: empty results
note right #salmon: Torrent was removed\nfrom qBit externally.
Worker -> DB: downloads.SetFailed(id, "torrent not found in client")
note right: Download marked as failed.\nNo further polls scheduled.\nNo retry.
== Case 3: Torrent in error state ==
River -> Worker: Work(PollDownloadArgs)
Worker -> QBit: Find(hash)
QBit --> Worker: TorrentInfo{state: "error"}
note right #salmon: qBit reports torrent error.\n(e.g. tracker unreachable,\ncorrupt data, disk full)
Worker -> DB: downloads.SetFailed(id, "torrent error state")
note right: Download marked as failed.\nNo further polls.\nTorrent remains in qBit.
== Case 4: Download completes, but SetCompleted fails ==
River -> Worker: Work(PollDownloadArgs)
Worker -> QBit: Find(hash)
QBit --> Worker: TorrentInfo{progress: 1.0, savePath: "/downloads"}
Worker -> DB: downloads.SetCompleted(id, "/downloads")
DB --> Worker: error (DB connection lost)
note right #salmon: Worker returns error.\nRiver will retry the job\n(built-in retry policy).\nDownload stays in\n"downloading" state.
== Case 5: File scan fails after completion ==
River -> Worker: Work(PollDownloadArgs)
Worker -> QBit: Find(hash)
QBit --> Worker: TorrentInfo{progress: 1.0, path: "/downloads/Album"}
Worker -> DB: downloads.SetCompleted(id, "/downloads")
DB --> Worker: OK
note right #lightgreen: Download marked completed.
Worker -> Worker: scanAndHashFiles("/downloads/Album")
Worker --> Worker: error (permission denied / path not found)
note right #orange: Logged as error.\nDownload IS completed.\nBut download_files NOT populated.\nGetAlbum won't show file info\nfor individual tracks.
== Case 6: File persist fails ==
River -> Worker: Work(PollDownloadArgs)
Worker -> QBit: Find(hash)
QBit --> Worker: TorrentInfo{progress: 1.0}
Worker -> DB: downloads.SetCompleted(id, savePath)
Worker -> Worker: scanAndHashFiles → 12 files
Worker -> DB: download_files.CreateBatch(files)
DB --> Worker: error (duplicate / constraint)
note right #orange: Download is completed.\nFiles not persisted.\nNon-fatal: returns nil.\nNo retry.
== Case 7: App crash during download (startup recovery) ==
note over River, Worker: Application restarts.\nRiver picks up persisted jobs.
River -> Worker: RecoverOrphanedDownloads()
Worker -> DB: downloads.GetActive()
DB --> Worker: [Download{state: downloading, hash: ...}]
Worker -> River: Insert(PollDownloadArgs, UniqueOpts{ByArgs})
note right #lightgreen: Deduplicated insert.\nIf River job already exists\n→ no duplicate.\nIf job was lost → recovered.\nPolling resumes.
@enduml
Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

@@ -0,0 +1,71 @@
@startuml MonitorAlbum - Torrent Client Failures
skinparam sequenceMessageAlign center
skinparam responseMessageBelowArrow true
title MonitorAlbum Error: Torrent Client Failures
actor Client
participant "MusicAgregatorService" as Service
participant "TorrentClient\n(qBittorrent)" as QBit
database "PostgreSQL" as DB
note over Service: Metadata fetched, indexer searched,\nbest release selected.
== Case 1: qBittorrent unreachable ==
Service -> QBit: Find(hash)
QBit --> Service: error (connection refused)
note right: Find() fails → skip existence check
Service -> QBit: AddMagnet(uri)
QBit --> Service: error (connection refused)
Service --> Client: error
note right #salmon: Album is persisted & monitored.\nNo torrent added.\nNo download record created.
== Case 2: Torrent already exists in qBit ==
Service -> QBit: Find(hash)
QBit --> Service: [TorrentInfo{state: stalledUP}]
note right #lightgreen: Torrent found.\nSkip AddMagnet/AddTorrent.
Service -> DB: torrents.Create (upsert)
Service -> DB: torrents.GetByInfoHash
DB --> Service: savedTorrent
Service -> DB: downloads.GetActiveByTorrentID(torrent_id)
DB --> Service: Download{state: completed}
note right #lightgreen: Active download exists.\nSkip duplicate insert.
Service --> Client: MonitorAlbumResponse
note right: Returns album + artist + release.\nNo duplicate download created.\nNo error.
== Case 3: AddTorrent fails (no torrent data) ==
Service -> QBit: Find(hash)
QBit --> Service: not found
note right: best.torrentData is nil\n(magnet resolve failed,\nfell back to title parse)
Service -> Service: len(best.torrentData) == 0
Service --> Client: error "no torrent data available"
note right #salmon: Magnet link but no\ntorrent data resolved.\nCannot add to client.
== Case 4: Torrent persists, but download insert fails ==
Service -> QBit: AddMagnet(uri)
QBit --> Service: OK
Service -> DB: torrents.Create (upsert)
Service -> DB: torrents.GetByInfoHash
DB --> Service: savedTorrent
Service -> DB: downloads.GetActiveByTorrentID
DB --> Service: not found
Service -> DB: downloads.Create(download)
DB --> Service: error (constraint violation)
note right #salmon: Logged as error.\nTorrent is in qBit but\nno download record.\nResponse still returned\n(non-fatal within saveTorrentAndDownload).\nNo poll job scheduled.
Service --> Client: MonitorAlbumResponse
note right #orange: Response includes release info.\nDownload info may be missing.\nTorrent is downloading in qBit\nbut untracked in DB.
@enduml
Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

@@ -0,0 +1,138 @@
@startuml MonitorAlbum Happy Path
skinparam sequenceMessageAlign center
skinparam responseMessageBelowArrow true
actor Client
participant "gRPC Server" as Server
participant "MusicAgregatorService" as Service
participant "MetadataService" as Metadata
database "metadata-agregator\n(gRPC)" as MetaGRPC
database "PostgreSQL" as DB
participant "IndexerService\n(Jackett)" as Indexer
participant "MagnetResolver" as Magnet
participant "TorrentClient\n(qBittorrent)" as QBit
participant "River Queue" as River
participant "PollDownloadWorker" as PollWorker
== 1. Fetch Album Metadata ==
Client -> Server: MonitorAlbum(album_id, quality, tracker)
Server -> Service: MonitorAlbum(ctx, req)
Service -> Metadata: GetAlbum(album_id)
Metadata -> MetaGRPC: GetAlbum(id)
MetaGRPC --> Metadata: Album (title, artists, genres, ...)
Metadata -> DB: albums.GetByExternalID(external_id)
note right: Check if album already persisted
DB --> Metadata: not found
Metadata -> DB: artists.Create(artist, state=monitored)
note right: Upsert artist\nnever downgrades\nmonitored/excluded
Metadata -> DB: albums.Create(album, state=monitored)
note right: Upsert album\nnever downgrades\nmonitored/excluded
Metadata --> Service: Album
== 2. Set Monitor State ==
Service -> DB: albums.GetByExternalID(external_id)
DB --> Service: dbAlbum
Service -> DB: albums.SetMonitorState(id, monitored)
note right: Explicitly mark\nalbum as monitored
== 3. Check If Already Owned ==
Service -> DB: downloads.HasAlbumInQuality(album_id, format, quality)
DB --> Service: false (not owned)
== 4. Search Indexers ==
Service -> Indexer: Search(artist + album title, tracker)
Indexer -> Indexer: Jackett API\n/api/v2.0/indexers/all/results
Indexer --> Service: SearchResponse (N items)
== 5. Parse & Resolve Releases ==
loop for each search result (with download link & seeders > 0)
alt magnet link
Service -> Magnet: Resolve(magnet_uri)
note right: DHT lookup, 30s timeout\n15s early exit if peers\nbut none active
Magnet --> Service: torrent metadata (files, hash, size)
Service -> Service: ParseTorrent(torrentData, album)
else HTTP torrent link
Service -> Service: downloadTorrentData(url)
Service -> Service: ParseTorrent(torrentData, album)
end
note right: Extract: format, bitDepth, sampleRate,\nsource, trackCount, coverArt, cueSheet, ripLog
end
== 6. Filter & Select Best ==
Service -> Service: filterByQuality(parsed, quality)
note right: Match LOSSLESS/LOSSY/UNSPECIFIED\nagainst release format
Service -> Service: selectBestRelease(filtered)
note right: Highest seeder count wins
== 7. Add to Torrent Client ==
Service -> QBit: Find(hash)
QBit --> Service: not found
alt magnet link
Service -> QBit: AddMagnet(magnet_uri)
else torrent file
Service -> QBit: AddTorrent(file)
end
QBit --> Service: OK
== 8. Persist Torrent & Download ==
Service -> DB: torrents.Create(torrent)
note right: Upsert on info_hash\nupdates seeders/peers
Service -> DB: torrents.GetByInfoHash(hash)
DB --> Service: savedTorrent (with DB id)
Service -> DB: downloads.GetActiveByTorrentID(torrent_id)
DB --> Service: not found (no active download)
Service -> DB: downloads.Create(download)
note right: state = "downloading"\nformat, quality, qbit_hash
DB --> Service: download (with DB id)
== 9. Schedule Download Poll ==
Service -> River: Insert(PollDownloadArgs)
note right: download_id, torrent_hash\ncheck_interval = 30s\nscheduled_at = now + 30s
River --> Service: job scheduled
== 10. Build & Return Response ==
Service -> DB: albums.GetByExternalID(external_id)
DB --> Service: dbAlbum (refreshed)
Service -> DB: downloads.GetByAlbumID(album_id)
DB --> Service: downloads (with state)
Service -> DB: artists.GetByExternalID(artist_external_id)
DB --> Service: dbArtist
Service --> Server: MonitorAlbumResponse
note right: album: id, title, monitor_state=monitored,\n download: state, format, quality\nartist: id, name, monitor_state\nrelease: hash, format, seeders, tracks
Server --> Client: MonitorAlbumResponse
== 11. Async: Download Polling (River Worker) ==
River -> PollWorker: Work(PollDownloadArgs)
PollWorker -> QBit: Find(hash)
QBit --> PollWorker: TorrentInfo (progress, state, path)
alt progress < 100%
PollWorker -> River: Insert(PollDownloadArgs)
note right: Reschedule after check_interval
else progress == 100%
PollWorker -> DB: downloads.SetCompleted(id, save_path)
PollWorker -> PollWorker: scanAndHashFiles(content_path)
note right: Walk directory, identify audio files\n(.flac, .mp3, .aac, ...)\nSHA-256 hash each file
PollWorker -> DB: download_files.CreateBatch(files)
note right: file_path, file_size, file_type,\nsha256_hash, verified_at
end
@enduml