package component import ( "context" "os" "path/filepath" "testing" "github.com/riverqueue/river" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "google.golang.org/grpc" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" metadataPb "homelab.lan/music-agregator/gen/metadata/v1" pb "homelab.lan/music-agregator/gen/music_agregator/v1" "homelab.lan/music-agregator/internal/database" "homelab.lan/music-agregator/internal/indexer" "homelab.lan/music-agregator/internal/torrent" "homelab.lan/music-agregator/internal/workers" ) func TestMonitorAlbum_HappyPath(t *testing.T) { suite := setupSuite(t) cleanTables(t, suite.pool) suite.mocks.metadata.GetAlbumFunc = func(ctx context.Context, in *metadataPb.GetAlbumRequest, opts ...grpc.CallOption) (*metadataPb.GetAlbumResponse, error) { return &metadataPb.GetAlbumResponse{ Album: newMetadataAlbum("test-album-ext-id", "Test Album", "artist-ext-id", "Test Artist"), }, nil } suite.mocks.indexer.SearchFunc = func(query string, limit int32, idx string) (*indexer.SearchResponse, error) { return newSearchResponse( newSearchItem("Test Artist - Test Album [FLAC]", 50, "magnet:?xt=urn:btih:abc123&dn=test"), newSearchItem("Test Artist - Test Album [MP3 320]", 10, "magnet:?xt=urn:btih:def456&dn=test"), ), nil } suite.mocks.magnet.ResolveFunc = func(magnetURI string) ([]byte, error) { return newTorrentData(), nil } suite.mocks.torrent.FindFunc = func(opts torrent.FindOptions) ([]torrent.TorrentInfo, error) { return []torrent.TorrentInfo{}, nil } suite.mocks.torrent.AddMagnetFunc = func(magnetURI string, savePath string) error { return nil } ctx := context.Background() resp, err := suite.client.MonitorAlbum(ctx, &pb.MonitorAlbumRequest{ AlbumId: "test-album-ext-id", Quality: pb.QualityType_QUALITY_LOSSLESS, }) require.NoError(t, err) require.NotNil(t, resp.Album) assert.NotEmpty(t, resp.Album.Id) assert.Equal(t, "Test Album", resp.Album.Title) assert.Equal(t, pb.MonitorState_MONITOR_STATE_MONITORED, resp.Album.MonitorState) require.NotNil(t, resp.Artist) assert.NotEmpty(t, resp.Artist.Id) assert.Equal(t, "Test Artist", resp.Artist.Name) assert.Equal(t, pb.MonitorState_MONITOR_STATE_MONITORED, resp.Artist.MonitorState) require.NotNil(t, resp.Release) assert.NotEmpty(t, resp.Release.InfoHash) assert.Equal(t, int32(50), resp.Release.Seeders) var artistMonitorState string err = suite.pool.QueryRow(ctx, "SELECT monitor_state FROM artists WHERE external_id = $1", "artist-ext-id").Scan(&artistMonitorState) require.NoError(t, err) assert.Equal(t, "monitored", artistMonitorState) var albumMonitorState string err = suite.pool.QueryRow(ctx, "SELECT monitor_state FROM albums WHERE external_id = $1", "test-album-ext-id").Scan(&albumMonitorState) require.NoError(t, err) assert.Equal(t, "monitored", albumMonitorState) var torrentCount int err = suite.pool.QueryRow(ctx, "SELECT COUNT(*) FROM torrents").Scan(&torrentCount) require.NoError(t, err) assert.Equal(t, 1, torrentCount) var downloadState string err = suite.pool.QueryRow(ctx, "SELECT state FROM downloads").Scan(&downloadState) require.NoError(t, err) assert.Equal(t, "downloading", downloadState) } func TestMonitorAlbum_MetadataUnavailable(t *testing.T) { suite := setupSuite(t) cleanTables(t, suite.pool) suite.mocks.metadata.GetAlbumFunc = func(ctx context.Context, in *metadataPb.GetAlbumRequest, opts ...grpc.CallOption) (*metadataPb.GetAlbumResponse, error) { return nil, status.Error(codes.Unavailable, "connection refused") } ctx := context.Background() _, err := suite.client.MonitorAlbum(ctx, &pb.MonitorAlbumRequest{ AlbumId: "test-album-ext-id", Quality: pb.QualityType_QUALITY_LOSSLESS, }) require.Error(t, err) var count int err = suite.pool.QueryRow(ctx, "SELECT COUNT(*) FROM artists").Scan(&count) require.NoError(t, err) assert.Equal(t, 0, count) err = suite.pool.QueryRow(ctx, "SELECT COUNT(*) FROM albums").Scan(&count) require.NoError(t, err) assert.Equal(t, 0, count) err = suite.pool.QueryRow(ctx, "SELECT COUNT(*) FROM torrents").Scan(&count) require.NoError(t, err) assert.Equal(t, 0, count) err = suite.pool.QueryRow(ctx, "SELECT COUNT(*) FROM downloads").Scan(&count) require.NoError(t, err) assert.Equal(t, 0, count) } func TestMonitorAlbum_MetadataNotFound(t *testing.T) { suite := setupSuite(t) cleanTables(t, suite.pool) suite.mocks.metadata.GetAlbumFunc = func(ctx context.Context, in *metadataPb.GetAlbumRequest, opts ...grpc.CallOption) (*metadataPb.GetAlbumResponse, error) { return nil, status.Error(codes.NotFound, "album not found") } ctx := context.Background() _, err := suite.client.MonitorAlbum(ctx, &pb.MonitorAlbumRequest{ AlbumId: "nonexistent-album", Quality: pb.QualityType_QUALITY_LOSSLESS, }) require.Error(t, err) var count int err = suite.pool.QueryRow(ctx, "SELECT COUNT(*) FROM artists").Scan(&count) require.NoError(t, err) assert.Equal(t, 0, count) err = suite.pool.QueryRow(ctx, "SELECT COUNT(*) FROM albums").Scan(&count) require.NoError(t, err) assert.Equal(t, 0, count) } func TestMonitorAlbum_ArtistPersistFails(t *testing.T) { suite := setupSuite(t) cleanTables(t, suite.pool) suite.mocks.metadata.GetAlbumFunc = func(ctx context.Context, in *metadataPb.GetAlbumRequest, opts ...grpc.CallOption) (*metadataPb.GetAlbumResponse, error) { return &metadataPb.GetAlbumResponse{ Album: &metadataPb.Album{ Id: "orphan-album-ext-id", Title: "Orphan Album", AlbumType: "album", ReleaseDate: "2024-01-15", TotalTracks: 10, Artists: []*metadataPb.ArtistCredit{}, }, }, nil } suite.mocks.indexer.SearchFunc = func(query string, limit int32, idx string) (*indexer.SearchResponse, error) { return newSearchResponse( newSearchItem("Orphan Album [FLAC]", 50, "magnet:?xt=urn:btih:abc123&dn=test"), ), nil } suite.mocks.magnet.ResolveFunc = func(magnetURI string) ([]byte, error) { return newTorrentData(), nil } suite.mocks.torrent.FindFunc = func(opts torrent.FindOptions) ([]torrent.TorrentInfo, error) { return []torrent.TorrentInfo{}, nil } suite.mocks.torrent.AddMagnetFunc = func(magnetURI string, savePath string) error { return nil } ctx := context.Background() resp, err := suite.client.MonitorAlbum(ctx, &pb.MonitorAlbumRequest{ AlbumId: "orphan-album-ext-id", Quality: pb.QualityType_QUALITY_LOSSLESS, }) require.NoError(t, err) require.NotNil(t, resp.Release) assert.Nil(t, resp.Album) var count int err = suite.pool.QueryRow(ctx, "SELECT COUNT(*) FROM downloads").Scan(&count) require.NoError(t, err) assert.Equal(t, 0, count) err = suite.pool.QueryRow(ctx, "SELECT COUNT(*) FROM torrents").Scan(&count) require.NoError(t, err) assert.Equal(t, 0, count) } func TestMonitorAlbum_AlreadyOwned(t *testing.T) { suite := setupSuite(t) cleanTables(t, suite.pool) ctx := context.Background() var artistID string err := suite.pool.QueryRow(ctx, ` INSERT INTO artists (external_id, name, artist_type, country, genres, image_url, monitor_state) VALUES ('artist-ext-id', 'Test Artist', 'person', 'US', ARRAY['Rock'], 'http://img.com', 'monitored') RETURNING id `).Scan(&artistID) require.NoError(t, err) var albumID string err = suite.pool.QueryRow(ctx, ` INSERT INTO albums (external_id, artist_id, title, album_type, total_tracks, total_discs, label, genres, cover_url, monitor_state) VALUES ('owned-album-ext-id', $1, 'Owned Album', 'album', 10, 1, 'Test Label', ARRAY['Rock'], 'http://cover.com', 'monitored') RETURNING id `, artistID).Scan(&albumID) require.NoError(t, err) var torrentID string err = suite.pool.QueryRow(ctx, ` INSERT INTO torrents (album_id, info_hash, tracker, title, format, quality, source, seeders, peers, size) VALUES ($1, 'existing-hash', 'test-tracker', 'Owned Album FLAC', 'FLAC', '16-44', 'CD', 100, 50, 500000000) RETURNING id `, albumID).Scan(&torrentID) require.NoError(t, err) _, err = suite.pool.Exec(ctx, ` INSERT INTO downloads (torrent_id, album_id, format, quality, state, qbit_hash, save_path) VALUES ($1, $2, 'QUALITY_LOSSLESS', '16-44', 'completed', 'existing-hash', '/music/owned') `, torrentID, albumID) require.NoError(t, err) suite.mocks.metadata.GetAlbumFunc = func(ctx context.Context, in *metadataPb.GetAlbumRequest, opts ...grpc.CallOption) (*metadataPb.GetAlbumResponse, error) { return &metadataPb.GetAlbumResponse{ Album: newMetadataAlbum("owned-album-ext-id", "Owned Album", "artist-ext-id", "Test Artist"), }, nil } var indexerCalled bool suite.mocks.indexer.SearchFunc = func(query string, limit int32, idx string) (*indexer.SearchResponse, error) { indexerCalled = true return newSearchResponse(), nil } resp, err := suite.client.MonitorAlbum(ctx, &pb.MonitorAlbumRequest{ AlbumId: "owned-album-ext-id", Quality: pb.QualityType_QUALITY_LOSSLESS, }) require.NoError(t, err) require.NotNil(t, resp.Album) assert.Equal(t, pb.MonitorState_MONITOR_STATE_MONITORED, resp.Album.MonitorState) require.NotNil(t, resp.Album.Download) assert.Equal(t, "completed", resp.Album.Download.State) assert.Nil(t, resp.Release) assert.False(t, indexerCalled) var downloadCount int err = suite.pool.QueryRow(ctx, "SELECT COUNT(*) FROM downloads").Scan(&downloadCount) require.NoError(t, err) assert.Equal(t, 1, downloadCount) } func TestMonitorAlbum_IndexerDown(t *testing.T) { suite := setupSuite(t) cleanTables(t, suite.pool) suite.mocks.metadata.GetAlbumFunc = func(ctx context.Context, in *metadataPb.GetAlbumRequest, opts ...grpc.CallOption) (*metadataPb.GetAlbumResponse, error) { return &metadataPb.GetAlbumResponse{ Album: newMetadataAlbum("test-album-ext-id", "Test Album", "artist-ext-id", "Test Artist"), }, nil } suite.mocks.indexer.SearchFunc = func(query string, limit int32, idx string) (*indexer.SearchResponse, error) { return nil, assert.AnError } ctx := context.Background() _, err := suite.client.MonitorAlbum(ctx, &pb.MonitorAlbumRequest{ AlbumId: "test-album-ext-id", Quality: pb.QualityType_QUALITY_LOSSLESS, }) require.Error(t, err) var artistMonitorState string err = suite.pool.QueryRow(ctx, "SELECT monitor_state FROM artists WHERE external_id = $1", "artist-ext-id").Scan(&artistMonitorState) require.NoError(t, err) assert.Equal(t, "monitored", artistMonitorState) var albumMonitorState string err = suite.pool.QueryRow(ctx, "SELECT monitor_state FROM albums WHERE external_id = $1", "test-album-ext-id").Scan(&albumMonitorState) require.NoError(t, err) assert.Equal(t, "monitored", albumMonitorState) } func TestMonitorAlbum_IndexerNoResults(t *testing.T) { suite := setupSuite(t) cleanTables(t, suite.pool) suite.mocks.metadata.GetAlbumFunc = func(ctx context.Context, in *metadataPb.GetAlbumRequest, opts ...grpc.CallOption) (*metadataPb.GetAlbumResponse, error) { return &metadataPb.GetAlbumResponse{ Album: newMetadataAlbum("test-album-ext-id", "Test Album", "artist-ext-id", "Test Artist"), }, nil } suite.mocks.indexer.SearchFunc = func(query string, limit int32, idx string) (*indexer.SearchResponse, error) { return newSearchResponse(), nil } ctx := context.Background() resp, err := suite.client.MonitorAlbum(ctx, &pb.MonitorAlbumRequest{ AlbumId: "test-album-ext-id", Quality: pb.QualityType_QUALITY_LOSSLESS, }) require.NoError(t, err) require.NotNil(t, resp.Album) require.NotNil(t, resp.Artist) assert.Nil(t, resp.Release) var count int err = suite.pool.QueryRow(ctx, "SELECT COUNT(*) FROM torrents").Scan(&count) require.NoError(t, err) assert.Equal(t, 0, count) err = suite.pool.QueryRow(ctx, "SELECT COUNT(*) FROM downloads").Scan(&count) require.NoError(t, err) assert.Equal(t, 0, count) } func TestMonitorAlbum_AllSeedersZero(t *testing.T) { suite := setupSuite(t) cleanTables(t, suite.pool) suite.mocks.metadata.GetAlbumFunc = func(ctx context.Context, in *metadataPb.GetAlbumRequest, opts ...grpc.CallOption) (*metadataPb.GetAlbumResponse, error) { return &metadataPb.GetAlbumResponse{ Album: newMetadataAlbum("test-album-ext-id", "Test Album", "artist-ext-id", "Test Artist"), }, nil } suite.mocks.indexer.SearchFunc = func(query string, limit int32, idx string) (*indexer.SearchResponse, error) { return newSearchResponse( newSearchItem("Test Album FLAC 1", 0, "magnet:?xt=urn:btih:abc1"), newSearchItem("Test Album FLAC 2", 0, "magnet:?xt=urn:btih:abc2"), newSearchItem("Test Album FLAC 3", 0, "magnet:?xt=urn:btih:abc3"), ), nil } ctx := context.Background() resp, err := suite.client.MonitorAlbum(ctx, &pb.MonitorAlbumRequest{ AlbumId: "test-album-ext-id", Quality: pb.QualityType_QUALITY_LOSSLESS, }) require.NoError(t, err) require.NotNil(t, resp.Album) require.NotNil(t, resp.Artist) assert.Nil(t, resp.Release) } func TestMonitorAlbum_AllMagnetsFail(t *testing.T) { suite := setupSuite(t) cleanTables(t, suite.pool) suite.mocks.metadata.GetAlbumFunc = func(ctx context.Context, in *metadataPb.GetAlbumRequest, opts ...grpc.CallOption) (*metadataPb.GetAlbumResponse, error) { return &metadataPb.GetAlbumResponse{ Album: newMetadataAlbum("test-album-ext-id", "Test Album", "artist-ext-id", "Test Artist"), }, nil } suite.mocks.indexer.SearchFunc = func(query string, limit int32, idx string) (*indexer.SearchResponse, error) { return newSearchResponse( newSearchItem("Test Album 1", 50, "magnet:?xt=urn:btih:abc1"), newSearchItem("Test Album 2", 30, "magnet:?xt=urn:btih:abc2"), ), nil } suite.mocks.magnet.ResolveFunc = func(magnetURI string) ([]byte, error) { return nil, assert.AnError } ctx := context.Background() resp, err := suite.client.MonitorAlbum(ctx, &pb.MonitorAlbumRequest{ AlbumId: "test-album-ext-id", Quality: pb.QualityType_QUALITY_LOSSLESS, }) require.NoError(t, err) require.NotNil(t, resp.Album) require.NotNil(t, resp.Artist) assert.Nil(t, resp.Release) } func TestMonitorAlbum_NoQualityMatch(t *testing.T) { suite := setupSuite(t) cleanTables(t, suite.pool) suite.mocks.metadata.GetAlbumFunc = func(ctx context.Context, in *metadataPb.GetAlbumRequest, opts ...grpc.CallOption) (*metadataPb.GetAlbumResponse, error) { return &metadataPb.GetAlbumResponse{ Album: newMetadataAlbum("test-album-ext-id", "Test Album", "artist-ext-id", "Test Artist"), }, nil } suite.mocks.indexer.SearchFunc = func(query string, limit int32, idx string) (*indexer.SearchResponse, error) { return newSearchResponse( newSearchItem("Test Album MP3 320", 50, "magnet:?xt=urn:btih:abc1"), newSearchItem("Test Album MP3 V0", 30, "magnet:?xt=urn:btih:abc2"), ), nil } suite.mocks.magnet.ResolveFunc = func(magnetURI string) ([]byte, error) { return newTorrentDataMP3(), nil } ctx := context.Background() resp, err := suite.client.MonitorAlbum(ctx, &pb.MonitorAlbumRequest{ AlbumId: "test-album-ext-id", Quality: pb.QualityType_QUALITY_LOSSLESS, }) require.NoError(t, err) require.NotNil(t, resp.Album) require.NotNil(t, resp.Artist) assert.Nil(t, resp.Release) } func TestMonitorAlbum_QBitDown(t *testing.T) { suite := setupSuite(t) cleanTables(t, suite.pool) suite.mocks.metadata.GetAlbumFunc = func(ctx context.Context, in *metadataPb.GetAlbumRequest, opts ...grpc.CallOption) (*metadataPb.GetAlbumResponse, error) { return &metadataPb.GetAlbumResponse{ Album: newMetadataAlbum("test-album-ext-id", "Test Album", "artist-ext-id", "Test Artist"), }, nil } suite.mocks.indexer.SearchFunc = func(query string, limit int32, idx string) (*indexer.SearchResponse, error) { return newSearchResponse( newSearchItem("Test Album [FLAC]", 50, "magnet:?xt=urn:btih:abc123"), ), nil } suite.mocks.magnet.ResolveFunc = func(magnetURI string) ([]byte, error) { return newTorrentData(), nil } suite.mocks.torrent.FindFunc = func(opts torrent.FindOptions) ([]torrent.TorrentInfo, error) { return nil, assert.AnError } suite.mocks.torrent.AddMagnetFunc = func(magnetURI string, savePath string) error { return assert.AnError } ctx := context.Background() _, err := suite.client.MonitorAlbum(ctx, &pb.MonitorAlbumRequest{ AlbumId: "test-album-ext-id", Quality: pb.QualityType_QUALITY_LOSSLESS, }) require.Error(t, err) var artistCount int err = suite.pool.QueryRow(ctx, "SELECT COUNT(*) FROM artists").Scan(&artistCount) require.NoError(t, err) assert.Equal(t, 1, artistCount) var albumCount int err = suite.pool.QueryRow(ctx, "SELECT COUNT(*) FROM albums").Scan(&albumCount) require.NoError(t, err) assert.Equal(t, 1, albumCount) var torrentCount int err = suite.pool.QueryRow(ctx, "SELECT COUNT(*) FROM torrents").Scan(&torrentCount) require.NoError(t, err) assert.Equal(t, 0, torrentCount) var downloadCount int err = suite.pool.QueryRow(ctx, "SELECT COUNT(*) FROM downloads").Scan(&downloadCount) require.NoError(t, err) assert.Equal(t, 0, downloadCount) } func TestMonitorAlbum_TorrentAlreadyExists(t *testing.T) { suite := setupSuite(t) cleanTables(t, suite.pool) ctx := context.Background() var artistID string err := suite.pool.QueryRow(ctx, ` INSERT INTO artists (external_id, name, artist_type, country, genres, image_url, monitor_state) VALUES ('artist-ext-id', 'Test Artist', 'person', 'US', ARRAY['Rock'], 'http://img.com', 'monitored') RETURNING id `).Scan(&artistID) require.NoError(t, err) var albumID string err = suite.pool.QueryRow(ctx, ` INSERT INTO albums (external_id, artist_id, title, album_type, total_tracks, total_discs, label, genres, cover_url, monitor_state) VALUES ('test-album-ext-id', $1, 'Test Album', 'album', 10, 1, 'Test Label', ARRAY['Rock'], 'http://cover.com', 'unmonitored') RETURNING id `, artistID).Scan(&albumID) require.NoError(t, err) torrentHash := "6ff7af15d0745a3e29d1b9620191cfe01ad3cc70" var torrentID string err = suite.pool.QueryRow(ctx, ` INSERT INTO torrents (album_id, info_hash, tracker, title, format, quality, source, seeders, peers, size) VALUES ($1, $2, 'test-tracker', 'Test Album FLAC', 'FLAC', '16-44', 'CD', 100, 50, 500000000) RETURNING id `, albumID, torrentHash).Scan(&torrentID) require.NoError(t, err) _, err = suite.pool.Exec(ctx, ` INSERT INTO downloads (torrent_id, album_id, format, quality, state, qbit_hash) VALUES ($1, $2, 'FLAC', '16-44', 'completed', $3) `, torrentID, albumID, torrentHash) require.NoError(t, err) suite.mocks.metadata.GetAlbumFunc = func(ctx context.Context, in *metadataPb.GetAlbumRequest, opts ...grpc.CallOption) (*metadataPb.GetAlbumResponse, error) { return &metadataPb.GetAlbumResponse{ Album: newMetadataAlbum("test-album-ext-id", "Test Album", "artist-ext-id", "Test Artist"), }, nil } suite.mocks.indexer.SearchFunc = func(query string, limit int32, idx string) (*indexer.SearchResponse, error) { return newSearchResponse( newSearchItem("Test Album FLAC", 50, "magnet:?xt=urn:btih:"+torrentHash), ), nil } suite.mocks.magnet.ResolveFunc = func(magnetURI string) ([]byte, error) { return newTorrentData(), nil } suite.mocks.torrent.FindFunc = func(opts torrent.FindOptions) ([]torrent.TorrentInfo, error) { return []torrent.TorrentInfo{{State: "stalledUP"}}, nil } resp, err := suite.client.MonitorAlbum(ctx, &pb.MonitorAlbumRequest{ AlbumId: "test-album-ext-id", Quality: pb.QualityType_QUALITY_LOSSLESS, }) require.NoError(t, err) require.NotNil(t, resp.Release) var downloadCount int err = suite.pool.QueryRow(ctx, "SELECT COUNT(*) FROM downloads").Scan(&downloadCount) require.NoError(t, err) assert.Equal(t, 1, downloadCount) } func TestMonitorAlbum_AddMagnetFails(t *testing.T) { suite := setupSuite(t) cleanTables(t, suite.pool) suite.mocks.metadata.GetAlbumFunc = func(ctx context.Context, in *metadataPb.GetAlbumRequest, opts ...grpc.CallOption) (*metadataPb.GetAlbumResponse, error) { return &metadataPb.GetAlbumResponse{ Album: newMetadataAlbum("test-album-ext-id", "Test Album", "artist-ext-id", "Test Artist"), }, nil } suite.mocks.indexer.SearchFunc = func(query string, limit int32, idx string) (*indexer.SearchResponse, error) { return newSearchResponse( newSearchItem("Test Album FLAC", 50, "magnet:?xt=urn:btih:abc123"), ), nil } suite.mocks.magnet.ResolveFunc = func(magnetURI string) ([]byte, error) { return newTorrentData(), nil } suite.mocks.torrent.FindFunc = func(opts torrent.FindOptions) ([]torrent.TorrentInfo, error) { return []torrent.TorrentInfo{}, nil } suite.mocks.torrent.AddMagnetFunc = func(magnetURI string, savePath string) error { return assert.AnError } ctx := context.Background() _, err := suite.client.MonitorAlbum(ctx, &pb.MonitorAlbumRequest{ AlbumId: "test-album-ext-id", Quality: pb.QualityType_QUALITY_LOSSLESS, }) require.Error(t, err) var albumCount int err = suite.pool.QueryRow(ctx, "SELECT COUNT(*) FROM albums").Scan(&albumCount) require.NoError(t, err) assert.Equal(t, 1, albumCount) var downloadCount int err = suite.pool.QueryRow(ctx, "SELECT COUNT(*) FROM downloads").Scan(&downloadCount) require.NoError(t, err) assert.Equal(t, 0, downloadCount) } func TestMonitorAlbum_DuplicateDownloadSkipped(t *testing.T) { suite := setupSuite(t) cleanTables(t, suite.pool) ctx := context.Background() var artistID string err := suite.pool.QueryRow(ctx, ` INSERT INTO artists (external_id, name, artist_type, country, genres, image_url, monitor_state) VALUES ('artist-ext-id', 'Test Artist', 'person', 'US', ARRAY['Rock'], 'http://img.com', 'monitored') RETURNING id `).Scan(&artistID) require.NoError(t, err) var albumID string err = suite.pool.QueryRow(ctx, ` INSERT INTO albums (external_id, artist_id, title, album_type, total_tracks, total_discs, label, genres, cover_url, monitor_state) VALUES ('test-album-ext-id', $1, 'Test Album', 'album', 10, 1, 'Test Label', ARRAY['Rock'], 'http://cover.com', 'unmonitored') RETURNING id `, artistID).Scan(&albumID) require.NoError(t, err) torrentHash := "6ff7af15d0745a3e29d1b9620191cfe01ad3cc70" var torrentID string err = suite.pool.QueryRow(ctx, ` INSERT INTO torrents (album_id, info_hash, tracker, title, format, quality, source, seeders, peers, size) VALUES ($1, $2, 'test-tracker', 'Test Album FLAC', 'FLAC', '16-44', 'CD', 100, 50, 500000000) RETURNING id `, albumID, torrentHash).Scan(&torrentID) require.NoError(t, err) _, err = suite.pool.Exec(ctx, ` INSERT INTO downloads (torrent_id, album_id, format, quality, state, qbit_hash) VALUES ($1, $2, 'FLAC', '16-44', 'downloading', $3) `, torrentID, albumID, torrentHash) require.NoError(t, err) suite.mocks.metadata.GetAlbumFunc = func(ctx context.Context, in *metadataPb.GetAlbumRequest, opts ...grpc.CallOption) (*metadataPb.GetAlbumResponse, error) { return &metadataPb.GetAlbumResponse{ Album: newMetadataAlbum("test-album-ext-id", "Test Album", "artist-ext-id", "Test Artist"), }, nil } suite.mocks.indexer.SearchFunc = func(query string, limit int32, idx string) (*indexer.SearchResponse, error) { return newSearchResponse( newSearchItem("Test Album FLAC", 50, "magnet:?xt=urn:btih:"+torrentHash), ), nil } suite.mocks.magnet.ResolveFunc = func(magnetURI string) ([]byte, error) { return newTorrentData(), nil } suite.mocks.torrent.FindFunc = func(opts torrent.FindOptions) ([]torrent.TorrentInfo, error) { return []torrent.TorrentInfo{{State: "downloading"}}, nil } resp, err := suite.client.MonitorAlbum(ctx, &pb.MonitorAlbumRequest{ AlbumId: "test-album-ext-id", Quality: pb.QualityType_QUALITY_LOSSLESS, }) require.NoError(t, err) require.NotNil(t, resp.Release) var downloadCount int err = suite.pool.QueryRow(ctx, "SELECT COUNT(*) FROM downloads").Scan(&downloadCount) require.NoError(t, err) assert.Equal(t, 1, downloadCount) } func TestMonitorAlbum_SearchQueryFormat(t *testing.T) { suite := setupSuite(t) cleanTables(t, suite.pool) suite.mocks.metadata.GetAlbumFunc = func(ctx context.Context, in *metadataPb.GetAlbumRequest, opts ...grpc.CallOption) (*metadataPb.GetAlbumResponse, error) { return &metadataPb.GetAlbumResponse{ Album: newMetadataAlbum("test-album-ext-id", "Test Album", "artist-ext-id", "Test Artist"), }, nil } var capturedQuery string suite.mocks.indexer.SearchFunc = func(query string, limit int32, idx string) (*indexer.SearchResponse, error) { capturedQuery = query return newSearchResponse(), nil } ctx := context.Background() _, err := suite.client.MonitorAlbum(ctx, &pb.MonitorAlbumRequest{ AlbumId: "test-album-ext-id", Quality: pb.QualityType_QUALITY_LOSSLESS, }) require.NoError(t, err) assert.Equal(t, "Test Artist Test Album", capturedQuery) } func TestPollWorker_QBitUnreachable(t *testing.T) { suite := setupSuite(t) cleanTables(t, suite.pool) ctx := context.Background() var artistID string err := suite.pool.QueryRow(ctx, ` INSERT INTO artists (external_id, name, artist_type, country, genres, image_url, monitor_state) VALUES ('artist-ext-id', 'Test Artist', 'person', 'US', ARRAY['Rock'], 'http://img.com', 'monitored') RETURNING id `).Scan(&artistID) require.NoError(t, err) var albumID string err = suite.pool.QueryRow(ctx, ` INSERT INTO albums (external_id, artist_id, title, album_type, total_tracks, total_discs, label, genres, cover_url, monitor_state) VALUES ('test-album-ext-id', $1, 'Test Album', 'album', 10, 1, 'Test Label', ARRAY['Rock'], 'http://cover.com', 'monitored') RETURNING id `, artistID).Scan(&albumID) require.NoError(t, err) var torrentID string err = suite.pool.QueryRow(ctx, ` INSERT INTO torrents (album_id, info_hash, tracker, title, format, quality, source, seeders, peers, size) VALUES ($1, 'poll-hash-123', 'test-tracker', 'Test Album FLAC', 'FLAC', '16-44', 'CD', 100, 50, 500000000) RETURNING id `, albumID).Scan(&torrentID) require.NoError(t, err) var downloadID string err = suite.pool.QueryRow(ctx, ` INSERT INTO downloads (torrent_id, album_id, format, quality, state, qbit_hash) VALUES ($1, $2, 'FLAC', '16-44', 'downloading', 'poll-hash-123') RETURNING id `, torrentID, albumID).Scan(&downloadID) require.NoError(t, err) mockTorrent := &mockTorrentClient{ FindFunc: func(opts torrent.FindOptions) ([]torrent.TorrentInfo, error) { return nil, assert.AnError }, } worker := &workers.PollDownloadWorker{ TorrentClient: mockTorrent, Downloads: database.NewDownloadRepository(suite.pool), DownloadFiles: database.NewDownloadFileRepository(suite.pool), RiverClient: nil, } job := &river.Job[workers.PollDownloadArgs]{ Args: workers.PollDownloadArgs{ DownloadID: downloadID, TorrentHash: "poll-hash-123", }, } err = worker.Work(ctx, job) require.NoError(t, err) var state string err = suite.pool.QueryRow(ctx, "SELECT state FROM downloads WHERE id = $1", downloadID).Scan(&state) require.NoError(t, err) assert.Equal(t, "downloading", state) } func TestPollWorker_TorrentDisappeared(t *testing.T) { suite := setupSuite(t) cleanTables(t, suite.pool) ctx := context.Background() var artistID string err := suite.pool.QueryRow(ctx, ` INSERT INTO artists (external_id, name, artist_type, country, genres, image_url, monitor_state) VALUES ('artist-ext-id', 'Test Artist', 'person', 'US', ARRAY['Rock'], 'http://img.com', 'monitored') RETURNING id `).Scan(&artistID) require.NoError(t, err) var albumID string err = suite.pool.QueryRow(ctx, ` INSERT INTO albums (external_id, artist_id, title, album_type, total_tracks, total_discs, label, genres, cover_url, monitor_state) VALUES ('test-album-ext-id', $1, 'Test Album', 'album', 10, 1, 'Test Label', ARRAY['Rock'], 'http://cover.com', 'monitored') RETURNING id `, artistID).Scan(&albumID) require.NoError(t, err) var torrentID string err = suite.pool.QueryRow(ctx, ` INSERT INTO torrents (album_id, info_hash, tracker, title, format, quality, source, seeders, peers, size) VALUES ($1, 'disappeared-hash', 'test-tracker', 'Test Album FLAC', 'FLAC', '16-44', 'CD', 100, 50, 500000000) RETURNING id `, albumID).Scan(&torrentID) require.NoError(t, err) var downloadID string err = suite.pool.QueryRow(ctx, ` INSERT INTO downloads (torrent_id, album_id, format, quality, state, qbit_hash) VALUES ($1, $2, 'FLAC', '16-44', 'downloading', 'disappeared-hash') RETURNING id `, torrentID, albumID).Scan(&downloadID) require.NoError(t, err) mockTorrent := &mockTorrentClient{ FindFunc: func(opts torrent.FindOptions) ([]torrent.TorrentInfo, error) { return []torrent.TorrentInfo{}, nil }, } worker := &workers.PollDownloadWorker{ TorrentClient: mockTorrent, Downloads: database.NewDownloadRepository(suite.pool), DownloadFiles: database.NewDownloadFileRepository(suite.pool), RiverClient: nil, } job := &river.Job[workers.PollDownloadArgs]{ Args: workers.PollDownloadArgs{ DownloadID: downloadID, TorrentHash: "disappeared-hash", }, } err = worker.Work(ctx, job) require.NoError(t, err) var state, errorMsg string err = suite.pool.QueryRow(ctx, "SELECT state, error_message FROM downloads WHERE id = $1", downloadID).Scan(&state, &errorMsg) require.NoError(t, err) assert.Equal(t, "failed", state) assert.Equal(t, "torrent not found in client", errorMsg) } func TestPollWorker_TorrentError(t *testing.T) { suite := setupSuite(t) cleanTables(t, suite.pool) ctx := context.Background() var artistID string err := suite.pool.QueryRow(ctx, ` INSERT INTO artists (external_id, name, artist_type, country, genres, image_url, monitor_state) VALUES ('artist-ext-id', 'Test Artist', 'person', 'US', ARRAY['Rock'], 'http://img.com', 'monitored') RETURNING id `).Scan(&artistID) require.NoError(t, err) var albumID string err = suite.pool.QueryRow(ctx, ` INSERT INTO albums (external_id, artist_id, title, album_type, total_tracks, total_discs, label, genres, cover_url, monitor_state) VALUES ('test-album-ext-id', $1, 'Test Album', 'album', 10, 1, 'Test Label', ARRAY['Rock'], 'http://cover.com', 'monitored') RETURNING id `, artistID).Scan(&albumID) require.NoError(t, err) var torrentID string err = suite.pool.QueryRow(ctx, ` INSERT INTO torrents (album_id, info_hash, tracker, title, format, quality, source, seeders, peers, size) VALUES ($1, 'error-hash', 'test-tracker', 'Test Album FLAC', 'FLAC', '16-44', 'CD', 100, 50, 500000000) RETURNING id `, albumID).Scan(&torrentID) require.NoError(t, err) var downloadID string err = suite.pool.QueryRow(ctx, ` INSERT INTO downloads (torrent_id, album_id, format, quality, state, qbit_hash) VALUES ($1, $2, 'FLAC', '16-44', 'downloading', 'error-hash') RETURNING id `, torrentID, albumID).Scan(&downloadID) require.NoError(t, err) mockTorrent := &mockTorrentClient{ FindFunc: func(opts torrent.FindOptions) ([]torrent.TorrentInfo, error) { return []torrent.TorrentInfo{{State: "error"}}, nil }, } worker := &workers.PollDownloadWorker{ TorrentClient: mockTorrent, Downloads: database.NewDownloadRepository(suite.pool), DownloadFiles: database.NewDownloadFileRepository(suite.pool), RiverClient: nil, } job := &river.Job[workers.PollDownloadArgs]{ Args: workers.PollDownloadArgs{ DownloadID: downloadID, TorrentHash: "error-hash", }, } err = worker.Work(ctx, job) require.NoError(t, err) var state, errorMsg string err = suite.pool.QueryRow(ctx, "SELECT state, error_message FROM downloads WHERE id = $1", downloadID).Scan(&state, &errorMsg) require.NoError(t, err) assert.Equal(t, "failed", state) assert.Equal(t, "torrent error state", errorMsg) } func TestPollWorker_CompletedSuccess(t *testing.T) { suite := setupSuite(t) cleanTables(t, suite.pool) ctx := context.Background() tempDir := t.TempDir() flacFile1 := filepath.Join(tempDir, "01-track.flac") flacFile2 := filepath.Join(tempDir, "02-track.flac") require.NoError(t, os.WriteFile(flacFile1, []byte("fake flac data 1"), 0644)) require.NoError(t, os.WriteFile(flacFile2, []byte("fake flac data 2"), 0644)) var artistID string err := suite.pool.QueryRow(ctx, ` INSERT INTO artists (external_id, name, artist_type, country, genres, image_url, monitor_state) VALUES ('artist-ext-id', 'Test Artist', 'person', 'US', ARRAY['Rock'], 'http://img.com', 'monitored') RETURNING id `).Scan(&artistID) require.NoError(t, err) var albumID string err = suite.pool.QueryRow(ctx, ` INSERT INTO albums (external_id, artist_id, title, album_type, total_tracks, total_discs, label, genres, cover_url, monitor_state) VALUES ('test-album-ext-id', $1, 'Test Album', 'album', 10, 1, 'Test Label', ARRAY['Rock'], 'http://cover.com', 'monitored') RETURNING id `, artistID).Scan(&albumID) require.NoError(t, err) var torrentID string err = suite.pool.QueryRow(ctx, ` INSERT INTO torrents (album_id, info_hash, tracker, title, format, quality, source, seeders, peers, size) VALUES ($1, 'completed-hash', 'test-tracker', 'Test Album FLAC', 'FLAC', '16-44', 'CD', 100, 50, 500000000) RETURNING id `, albumID).Scan(&torrentID) require.NoError(t, err) var downloadID string err = suite.pool.QueryRow(ctx, ` INSERT INTO downloads (torrent_id, album_id, format, quality, state, qbit_hash) VALUES ($1, $2, 'FLAC', '16-44', 'downloading', 'completed-hash') RETURNING id `, torrentID, albumID).Scan(&downloadID) require.NoError(t, err) mockTorrent := &mockTorrentClient{ FindFunc: func(opts torrent.FindOptions) ([]torrent.TorrentInfo, error) { return []torrent.TorrentInfo{{ Progress: 1.0, SavePath: "/downloads", ContentPath: tempDir, }}, nil }, } worker := &workers.PollDownloadWorker{ TorrentClient: mockTorrent, Downloads: database.NewDownloadRepository(suite.pool), DownloadFiles: database.NewDownloadFileRepository(suite.pool), RiverClient: nil, } job := &river.Job[workers.PollDownloadArgs]{ Args: workers.PollDownloadArgs{ DownloadID: downloadID, TorrentHash: "completed-hash", }, } err = worker.Work(ctx, job) require.NoError(t, err) var state string var savePath *string err = suite.pool.QueryRow(ctx, "SELECT state, save_path FROM downloads WHERE id = $1", downloadID).Scan(&state, &savePath) require.NoError(t, err) assert.Equal(t, "completed", state) require.NotNil(t, savePath) assert.Equal(t, "/downloads", *savePath) var fileCount int err = suite.pool.QueryRow(ctx, "SELECT COUNT(*) FROM download_files WHERE download_id = $1", downloadID).Scan(&fileCount) require.NoError(t, err) assert.Equal(t, 2, fileCount) var fileTypes []string rows, err := suite.pool.Query(ctx, "SELECT file_type FROM download_files WHERE download_id = $1", downloadID) require.NoError(t, err) defer rows.Close() for rows.Next() { var ft string require.NoError(t, rows.Scan(&ft)) fileTypes = append(fileTypes, ft) } assert.Contains(t, fileTypes, "flac") } func TestPollWorker_FileScanFails(t *testing.T) { suite := setupSuite(t) cleanTables(t, suite.pool) ctx := context.Background() var artistID string err := suite.pool.QueryRow(ctx, ` INSERT INTO artists (external_id, name, artist_type, country, genres, image_url, monitor_state) VALUES ('artist-ext-id', 'Test Artist', 'person', 'US', ARRAY['Rock'], 'http://img.com', 'monitored') RETURNING id `).Scan(&artistID) require.NoError(t, err) var albumID string err = suite.pool.QueryRow(ctx, ` INSERT INTO albums (external_id, artist_id, title, album_type, total_tracks, total_discs, label, genres, cover_url, monitor_state) VALUES ('test-album-ext-id', $1, 'Test Album', 'album', 10, 1, 'Test Label', ARRAY['Rock'], 'http://cover.com', 'monitored') RETURNING id `, artistID).Scan(&albumID) require.NoError(t, err) var torrentID string err = suite.pool.QueryRow(ctx, ` INSERT INTO torrents (album_id, info_hash, tracker, title, format, quality, source, seeders, peers, size) VALUES ($1, 'scan-fail-hash', 'test-tracker', 'Test Album FLAC', 'FLAC', '16-44', 'CD', 100, 50, 500000000) RETURNING id `, albumID).Scan(&torrentID) require.NoError(t, err) var downloadID string err = suite.pool.QueryRow(ctx, ` INSERT INTO downloads (torrent_id, album_id, format, quality, state, qbit_hash) VALUES ($1, $2, 'FLAC', '16-44', 'downloading', 'scan-fail-hash') RETURNING id `, torrentID, albumID).Scan(&downloadID) require.NoError(t, err) mockTorrent := &mockTorrentClient{ FindFunc: func(opts torrent.FindOptions) ([]torrent.TorrentInfo, error) { return []torrent.TorrentInfo{{ Progress: 1.0, SavePath: "/downloads", ContentPath: "/nonexistent/path/that/does/not/exist", }}, nil }, } worker := &workers.PollDownloadWorker{ TorrentClient: mockTorrent, Downloads: database.NewDownloadRepository(suite.pool), DownloadFiles: database.NewDownloadFileRepository(suite.pool), RiverClient: nil, } job := &river.Job[workers.PollDownloadArgs]{ Args: workers.PollDownloadArgs{ DownloadID: downloadID, TorrentHash: "scan-fail-hash", }, } err = worker.Work(ctx, job) require.NoError(t, err) var state string err = suite.pool.QueryRow(ctx, "SELECT state FROM downloads WHERE id = $1", downloadID).Scan(&state) require.NoError(t, err) assert.Equal(t, "completed", state) var fileCount int err = suite.pool.QueryRow(ctx, "SELECT COUNT(*) FROM download_files WHERE download_id = $1", downloadID).Scan(&fileCount) require.NoError(t, err) assert.Equal(t, 0, fileCount) } func TestRecoverOrphaned_FindsActiveDownloads(t *testing.T) { suite := setupSuite(t) cleanTables(t, suite.pool) ctx := context.Background() var artistID string err := suite.pool.QueryRow(ctx, ` INSERT INTO artists (external_id, name, artist_type, country, genres, image_url, monitor_state) VALUES ('artist-ext-id', 'Test Artist', 'person', 'US', ARRAY['Rock'], 'http://img.com', 'monitored') RETURNING id `).Scan(&artistID) require.NoError(t, err) var albumID string err = suite.pool.QueryRow(ctx, ` INSERT INTO albums (external_id, artist_id, title, album_type, total_tracks, total_discs, label, genres, cover_url, monitor_state) VALUES ('test-album-ext-id', $1, 'Test Album', 'album', 10, 1, 'Test Label', ARRAY['Rock'], 'http://cover.com', 'monitored') RETURNING id `, artistID).Scan(&albumID) require.NoError(t, err) var torrentID string err = suite.pool.QueryRow(ctx, ` INSERT INTO torrents (album_id, info_hash, tracker, title, format, quality, source, seeders, peers, size) VALUES ($1, 'orphan-hash', 'test-tracker', 'Test Album FLAC', 'FLAC', '16-44', 'CD', 100, 50, 500000000) RETURNING id `, albumID).Scan(&torrentID) require.NoError(t, err) var downloadID string err = suite.pool.QueryRow(ctx, ` INSERT INTO downloads (torrent_id, album_id, format, quality, state, qbit_hash) VALUES ($1, $2, 'FLAC', '16-44', 'downloading', 'orphan-hash') RETURNING id `, torrentID, albumID).Scan(&downloadID) require.NoError(t, err) downloads := database.NewDownloadRepository(suite.pool) active, err := downloads.GetActive(ctx) require.NoError(t, err) require.Len(t, active, 1) assert.Equal(t, downloadID, active[0].ID) assert.Equal(t, "orphan-hash", active[0].QbitHash) assert.Equal(t, "downloading", active[0].State) }