package component import ( "context" "io" "testing" "time" "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" "homelab.lan/music-agregator/internal/indexer" "homelab.lan/music-agregator/internal/torrent" ) const defaultStreamTimeout = 5 * time.Second func startMonitorStream( t *testing.T, client pb.MusicAgregatorServiceClient, req *pb.StartMonitorRequest, ) grpc.BidiStreamingClient[pb.MonitorAlbumStreamRequest, pb.MonitorAlbumStreamResponse] { t.Helper() ctx, cancel := context.WithTimeout(context.Background(), defaultStreamTimeout) t.Cleanup(cancel) stream, err := client.MonitorAlbumStream(ctx) require.NoError(t, err) err = stream.Send(&pb.MonitorAlbumStreamRequest{ Message: &pb.MonitorAlbumStreamRequest_Start{Start: req}, }) require.NoError(t, err) return stream } func collectAllMessages( t *testing.T, stream grpc.BidiStreamingClient[pb.MonitorAlbumStreamRequest, pb.MonitorAlbumStreamResponse], timeout time.Duration, ) []*pb.MonitorAlbumStreamResponse { t.Helper() if timeout == 0 { timeout = defaultStreamTimeout } ctx, cancel := context.WithTimeout(context.Background(), timeout) defer cancel() var messages []*pb.MonitorAlbumStreamResponse done := make(chan struct{}) go func() { defer close(done) for { msg, err := stream.Recv() if err == io.EOF { return } if err != nil { return } messages = append(messages, msg) } }() select { case <-done: case <-ctx.Done(): t.Fatalf("collectAllMessages timed out after %v", timeout) } return messages } func collectUntilPrompt( t *testing.T, stream grpc.BidiStreamingClient[pb.MonitorAlbumStreamRequest, pb.MonitorAlbumStreamResponse], timeout time.Duration, ) ([]*pb.MonitorAlbumStreamResponse, *pb.PromptForDecision, error) { t.Helper() if timeout == 0 { timeout = defaultStreamTimeout } ctx, cancel := context.WithTimeout(context.Background(), timeout) defer cancel() var messages []*pb.MonitorAlbumStreamResponse resultChan := make(chan struct { prompt *pb.PromptForDecision err error }, 1) go func() { for { msg, err := stream.Recv() if err == io.EOF { resultChan <- struct { prompt *pb.PromptForDecision err error }{nil, io.EOF} return } if err != nil { resultChan <- struct { prompt *pb.PromptForDecision err error }{nil, err} return } messages = append(messages, msg) if prompt := msg.GetPrompt(); prompt != nil { resultChan <- struct { prompt *pb.PromptForDecision err error }{prompt, nil} return } if errUpdate := msg.GetError(); errUpdate != nil { resultChan <- struct { prompt *pb.PromptForDecision err error }{nil, status.Error(codes.Internal, errUpdate.Message)} return } } }() select { case result := <-resultChan: return messages, result.prompt, result.err case <-ctx.Done(): t.Fatalf("collectUntilPrompt timed out after %v", timeout) return nil, nil, ctx.Err() } } func sendDecision( t *testing.T, stream grpc.BidiStreamingClient[pb.MonitorAlbumStreamRequest, pb.MonitorAlbumStreamResponse], promptID string, decision *pb.UserDecision, ) { t.Helper() decision.PromptId = promptID err := stream.Send(&pb.MonitorAlbumStreamRequest{ Message: &pb.MonitorAlbumStreamRequest_Decision{Decision: decision}, }) require.NoError(t, err) } func assertStatusSequence(t *testing.T, messages []*pb.MonitorAlbumStreamResponse, expectedSteps []pb.MonitorStep) { t.Helper() var actualSteps []pb.MonitorStep for _, msg := range messages { if status := msg.GetStatus(); status != nil { actualSteps = append(actualSteps, status.Step) } } assert.Equal(t, expectedSteps, actualSteps, "status step sequence mismatch") } func assertContainsStep(t *testing.T, messages []*pb.MonitorAlbumStreamResponse, step pb.MonitorStep) { t.Helper() for _, msg := range messages { if status := msg.GetStatus(); status != nil && status.Step == step { return } } t.Errorf("expected messages to contain step %v", step) } func TestMonitorAlbumStream_AutomaticHappyPath(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 } stream := startMonitorStream(t, suite.client, &pb.StartMonitorRequest{ AlbumId: "test-album-ext-id", Quality: pb.QualityType_QUALITY_LOSSLESS, Mode: pb.InteractionMode_INTERACTION_MODE_AUTOMATIC, }) messages := collectAllMessages(t, stream, 0) assertStatusSequence(t, messages, []pb.MonitorStep{ pb.MonitorStep_MONITOR_STEP_FETCHING_METADATA, pb.MonitorStep_MONITOR_STEP_FETCHING_METADATA, pb.MonitorStep_MONITOR_STEP_CHECKING_OWNED, pb.MonitorStep_MONITOR_STEP_SEARCHING_INDEXER, pb.MonitorStep_MONITOR_STEP_PARSING_RESULTS, pb.MonitorStep_MONITOR_STEP_FILTERING_QUALITY, pb.MonitorStep_MONITOR_STEP_SELECTING_RELEASE, pb.MonitorStep_MONITOR_STEP_ADDING_TORRENT, pb.MonitorStep_MONITOR_STEP_SAVING, pb.MonitorStep_MONITOR_STEP_COMPLETE, }) var result *pb.MonitorAlbumResponse for _, msg := range messages { if r := msg.GetResult(); r != nil { result = r break } } require.NotNil(t, result) require.NotNil(t, result.Album) assert.Equal(t, "Test Album", result.Album.Title) require.NotNil(t, result.Release) ctx := context.Background() var torrentCount int err := suite.pool.QueryRow(ctx, "SELECT COUNT(*) FROM torrents").Scan(&torrentCount) require.NoError(t, err) assert.Equal(t, 1, torrentCount) } func TestMonitorAlbumStream_AutomaticMetadataUnavailable(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") } stream := startMonitorStream(t, suite.client, &pb.StartMonitorRequest{ AlbumId: "test-album-ext-id", Quality: pb.QualityType_QUALITY_LOSSLESS, Mode: pb.InteractionMode_INTERACTION_MODE_AUTOMATIC, }) messages := collectAllMessages(t, stream, 0) var hasError bool for _, msg := range messages { if errUpdate := msg.GetError(); errUpdate != nil { hasError = true assert.Equal(t, pb.MonitorStep_MONITOR_STEP_FETCHING_METADATA, errUpdate.FailedStep) break } } assert.True(t, hasError, "expected error message for metadata unavailable") ctx := context.Background() var count int err := suite.pool.QueryRow(ctx, "SELECT COUNT(*) FROM artists").Scan(&count) require.NoError(t, err) assert.Equal(t, 0, count) } func TestMonitorAlbumStream_AutomaticMetadataNotFound(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") } stream := startMonitorStream(t, suite.client, &pb.StartMonitorRequest{ AlbumId: "nonexistent-album", Quality: pb.QualityType_QUALITY_LOSSLESS, Mode: pb.InteractionMode_INTERACTION_MODE_AUTOMATIC, }) messages := collectAllMessages(t, stream, 0) var hasError bool for _, msg := range messages { if errUpdate := msg.GetError(); errUpdate != nil { hasError = true assert.Equal(t, pb.MonitorStep_MONITOR_STEP_FETCHING_METADATA, errUpdate.FailedStep) break } } assert.True(t, hasError, "expected error message for metadata not found") ctx := context.Background() var count int err := suite.pool.QueryRow(ctx, "SELECT COUNT(*) FROM albums").Scan(&count) require.NoError(t, err) assert.Equal(t, 0, count) } func TestMonitorAlbumStream_AutomaticArtistPersistFails(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 } stream := startMonitorStream(t, suite.client, &pb.StartMonitorRequest{ AlbumId: "orphan-album-ext-id", Quality: pb.QualityType_QUALITY_LOSSLESS, Mode: pb.InteractionMode_INTERACTION_MODE_AUTOMATIC, }) messages := collectAllMessages(t, stream, 0) var result *pb.MonitorAlbumResponse for _, msg := range messages { if r := msg.GetResult(); r != nil { result = r break } } require.NotNil(t, result) require.NotNil(t, result.Release) assert.Nil(t, result.Album) ctx := context.Background() var count int err := suite.pool.QueryRow(ctx, "SELECT COUNT(*) FROM downloads").Scan(&count) require.NoError(t, err) assert.Equal(t, 0, count) } func TestMonitorAlbumStream_AutomaticAlreadyOwned(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 } stream := startMonitorStream(t, suite.client, &pb.StartMonitorRequest{ AlbumId: "owned-album-ext-id", Quality: pb.QualityType_QUALITY_LOSSLESS, Mode: pb.InteractionMode_INTERACTION_MODE_AUTOMATIC, }) messages := collectAllMessages(t, stream, 0) assertContainsStep(t, messages, pb.MonitorStep_MONITOR_STEP_CHECKING_OWNED) var result *pb.MonitorAlbumResponse for _, msg := range messages { if r := msg.GetResult(); r != nil { result = r break } } require.NotNil(t, result) require.NotNil(t, result.Album) assert.Equal(t, pb.MonitorState_MONITOR_STATE_MONITORED, result.Album.MonitorState) assert.Nil(t, result.Release) assert.False(t, indexerCalled) } func TestMonitorAlbumStream_AutomaticIndexerDown(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 } stream := startMonitorStream(t, suite.client, &pb.StartMonitorRequest{ AlbumId: "test-album-ext-id", Quality: pb.QualityType_QUALITY_LOSSLESS, Mode: pb.InteractionMode_INTERACTION_MODE_AUTOMATIC, }) messages := collectAllMessages(t, stream, 0) var hasError bool for _, msg := range messages { if errUpdate := msg.GetError(); errUpdate != nil { hasError = true assert.Equal(t, pb.MonitorStep_MONITOR_STEP_SEARCHING_INDEXER, errUpdate.FailedStep) break } } assert.True(t, hasError, "expected error message for indexer down") } func TestMonitorAlbumStream_AutomaticNoResults(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 } stream := startMonitorStream(t, suite.client, &pb.StartMonitorRequest{ AlbumId: "test-album-ext-id", Quality: pb.QualityType_QUALITY_LOSSLESS, Mode: pb.InteractionMode_INTERACTION_MODE_AUTOMATIC, }) messages := collectAllMessages(t, stream, 0) assertContainsStep(t, messages, pb.MonitorStep_MONITOR_STEP_COMPLETE) var result *pb.MonitorAlbumResponse for _, msg := range messages { if r := msg.GetResult(); r != nil { result = r break } } require.NotNil(t, result) require.NotNil(t, result.Album) assert.Nil(t, result.Release) ctx := context.Background() var count int err := suite.pool.QueryRow(ctx, "SELECT COUNT(*) FROM torrents").Scan(&count) require.NoError(t, err) assert.Equal(t, 0, count) } func TestMonitorAlbumStream_AutomaticAllSeedersZero(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 } stream := startMonitorStream(t, suite.client, &pb.StartMonitorRequest{ AlbumId: "test-album-ext-id", Quality: pb.QualityType_QUALITY_LOSSLESS, Mode: pb.InteractionMode_INTERACTION_MODE_AUTOMATIC, }) messages := collectAllMessages(t, stream, 0) var result *pb.MonitorAlbumResponse for _, msg := range messages { if r := msg.GetResult(); r != nil { result = r break } } require.NotNil(t, result) require.NotNil(t, result.Album) assert.Nil(t, result.Release) } func TestMonitorAlbumStream_AutomaticAllMagnetsFail(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 } stream := startMonitorStream(t, suite.client, &pb.StartMonitorRequest{ AlbumId: "test-album-ext-id", Quality: pb.QualityType_QUALITY_LOSSLESS, Mode: pb.InteractionMode_INTERACTION_MODE_AUTOMATIC, }) messages := collectAllMessages(t, stream, 0) var result *pb.MonitorAlbumResponse for _, msg := range messages { if r := msg.GetResult(); r != nil { result = r break } } require.NotNil(t, result) require.NotNil(t, result.Album) assert.Nil(t, result.Release) } func TestMonitorAlbumStream_AutomaticNoQualityMatch(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 } stream := startMonitorStream(t, suite.client, &pb.StartMonitorRequest{ AlbumId: "test-album-ext-id", Quality: pb.QualityType_QUALITY_LOSSLESS, Mode: pb.InteractionMode_INTERACTION_MODE_AUTOMATIC, }) messages := collectAllMessages(t, stream, 0) assertContainsStep(t, messages, pb.MonitorStep_MONITOR_STEP_FILTERING_QUALITY) var result *pb.MonitorAlbumResponse for _, msg := range messages { if r := msg.GetResult(); r != nil { result = r break } } require.NotNil(t, result) require.NotNil(t, result.Album) assert.Nil(t, result.Release) } func TestMonitorAlbumStream_AutomaticQBitDown(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 } stream := startMonitorStream(t, suite.client, &pb.StartMonitorRequest{ AlbumId: "test-album-ext-id", Quality: pb.QualityType_QUALITY_LOSSLESS, Mode: pb.InteractionMode_INTERACTION_MODE_AUTOMATIC, }) messages := collectAllMessages(t, stream, 0) var hasError bool for _, msg := range messages { if errUpdate := msg.GetError(); errUpdate != nil { hasError = true assert.Equal(t, pb.MonitorStep_MONITOR_STEP_ADDING_TORRENT, errUpdate.FailedStep) break } } assert.True(t, hasError, "expected error message for qbit down") ctx := context.Background() var downloadCount int err := suite.pool.QueryRow(ctx, "SELECT COUNT(*) FROM downloads").Scan(&downloadCount) require.NoError(t, err) assert.Equal(t, 1, downloadCount, "download record should exist even when qBit fails (DB save happens before qBit)") } func TestMonitorAlbumStream_AutomaticTorrentExists(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 } stream := startMonitorStream(t, suite.client, &pb.StartMonitorRequest{ AlbumId: "test-album-ext-id", Quality: pb.QualityType_QUALITY_LOSSLESS, Mode: pb.InteractionMode_INTERACTION_MODE_AUTOMATIC, }) messages := collectAllMessages(t, stream, 0) var result *pb.MonitorAlbumResponse for _, msg := range messages { if r := msg.GetResult(); r != nil { result = r break } } require.NotNil(t, result) require.NotNil(t, result.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 TestMonitorAlbumStream_AutomaticDuplicateSkipped(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 } stream := startMonitorStream(t, suite.client, &pb.StartMonitorRequest{ AlbumId: "test-album-ext-id", Quality: pb.QualityType_QUALITY_LOSSLESS, Mode: pb.InteractionMode_INTERACTION_MODE_AUTOMATIC, }) messages := collectAllMessages(t, stream, 0) var result *pb.MonitorAlbumResponse for _, msg := range messages { if r := msg.GetResult(); r != nil { result = r break } } require.NotNil(t, result) require.NotNil(t, result.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 TestMonitorAlbumStream_ManualSelectTorrents(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 24/96", 50, "magnet:?xt=urn:btih:hash1"), newSearchItem("Test Artist - Test Album FLAC 16/44", 100, "magnet:?xt=urn:btih:hash2"), newSearchItem("Test Artist - Test Album MP3", 200, "magnet:?xt=urn:btih:hash3"), ), 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, cancel := context.WithTimeout(context.Background(), defaultStreamTimeout) defer cancel() stream, err := suite.client.MonitorAlbumStream(ctx) require.NoError(t, err) err = stream.Send(&pb.MonitorAlbumStreamRequest{ Message: &pb.MonitorAlbumStreamRequest_Start{ Start: &pb.StartMonitorRequest{ AlbumId: "test-album-ext-id", Quality: pb.QualityType_QUALITY_LOSSLESS, Mode: pb.InteractionMode_INTERACTION_MODE_MANUAL, }, }, }) require.NoError(t, err) messages, prompt, err := collectUntilPrompt(t, stream, 0) require.NoError(t, err) require.NotNil(t, prompt) assert.Equal(t, pb.PromptType_PROMPT_TYPE_SELECT_MANY, prompt.Type) require.NotNil(t, prompt.GetSelectMany()) assert.GreaterOrEqual(t, len(prompt.GetSelectMany().Options), 2) assertContainsStep(t, messages, pb.MonitorStep_MONITOR_STEP_PARSING_RESULTS) sendDecision(t, stream, prompt.PromptId, &pb.UserDecision{ Decision: &pb.UserDecision_SelectedIds{ SelectedIds: &pb.SelectedIds{ Ids: []string{prompt.GetSelectMany().Options[0].Id, prompt.GetSelectMany().Options[1].Id}, }, }, }) _, selectOnePrompt, err := collectUntilPrompt(t, stream, 0) require.NoError(t, err) require.NotNil(t, selectOnePrompt) assert.Equal(t, pb.PromptType_PROMPT_TYPE_SELECT_ONE, selectOnePrompt.Type) sendDecision(t, stream, selectOnePrompt.PromptId, &pb.UserDecision{ Decision: &pb.UserDecision_SelectedId{SelectedId: selectOnePrompt.GetSelectOne().DefaultId}, }) _, confirmPrompt, err := collectUntilPrompt(t, stream, 0) require.NoError(t, err) require.NotNil(t, confirmPrompt) assert.Equal(t, pb.PromptType_PROMPT_TYPE_CONFIRM, confirmPrompt.Type) sendDecision(t, stream, confirmPrompt.PromptId, &pb.UserDecision{ Decision: &pb.UserDecision_Confirm{Confirm: true}, }) remaining := collectAllMessages(t, stream, 0) var result *pb.MonitorAlbumResponse for _, msg := range remaining { if r := msg.GetResult(); r != nil { result = r break } } require.NotNil(t, result) } func TestMonitorAlbumStream_ManualSelectRelease(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 24/96", 50, "magnet:?xt=urn:btih:hash1"), newSearchItem("Test Artist - Test Album FLAC 16/44", 100, "magnet:?xt=urn:btih:hash2"), ), 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, cancel := context.WithTimeout(context.Background(), defaultStreamTimeout) defer cancel() stream, err := suite.client.MonitorAlbumStream(ctx) require.NoError(t, err) err = stream.Send(&pb.MonitorAlbumStreamRequest{ Message: &pb.MonitorAlbumStreamRequest_Start{ Start: &pb.StartMonitorRequest{ AlbumId: "test-album-ext-id", Quality: pb.QualityType_QUALITY_LOSSLESS, Mode: pb.InteractionMode_INTERACTION_MODE_MANUAL, }, }, }) require.NoError(t, err) messages, prompt, err := collectUntilPrompt(t, stream, 0) require.NoError(t, err) require.NotNil(t, prompt) assert.Equal(t, pb.PromptType_PROMPT_TYPE_SELECT_MANY, prompt.Type) require.NotNil(t, prompt.GetSelectMany()) assert.Len(t, prompt.GetSelectMany().Options, 2) assertContainsStep(t, messages, pb.MonitorStep_MONITOR_STEP_PARSING_RESULTS) sendDecision(t, stream, prompt.PromptId, &pb.UserDecision{ Decision: &pb.UserDecision_SelectedIds{SelectedIds: &pb.SelectedIds{Ids: prompt.GetSelectMany().DefaultIds}}, }) _, selectOnePrompt, err := collectUntilPrompt(t, stream, 0) require.NoError(t, err) require.NotNil(t, selectOnePrompt) assert.Equal(t, pb.PromptType_PROMPT_TYPE_SELECT_ONE, selectOnePrompt.Type) require.NotNil(t, selectOnePrompt.GetSelectOne()) assert.Len(t, selectOnePrompt.GetSelectOne().Options, 2) selectedOption := selectOnePrompt.GetSelectOne().Options[1] sendDecision(t, stream, selectOnePrompt.PromptId, &pb.UserDecision{ Decision: &pb.UserDecision_SelectedId{SelectedId: selectedOption.Id}, }) _, confirmPrompt, err := collectUntilPrompt(t, stream, 0) require.NoError(t, err) require.NotNil(t, confirmPrompt) assert.Equal(t, pb.PromptType_PROMPT_TYPE_CONFIRM, confirmPrompt.Type) sendDecision(t, stream, confirmPrompt.PromptId, &pb.UserDecision{ Decision: &pb.UserDecision_Confirm{Confirm: true}, }) remaining := collectAllMessages(t, stream, 0) var result *pb.MonitorAlbumResponse for _, msg := range remaining { if r := msg.GetResult(); r != nil { result = r break } } require.NotNil(t, result) require.NotNil(t, result.Release) assert.Equal(t, int32(100), result.Release.Seeders) } func TestMonitorAlbumStream_ManualConfirmAdd(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"), ), 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 } var addMagnetCalled bool suite.mocks.torrent.AddMagnetFunc = func(magnetURI string, savePath string) error { addMagnetCalled = true return nil } ctx, cancel := context.WithTimeout(context.Background(), defaultStreamTimeout) defer cancel() stream, err := suite.client.MonitorAlbumStream(ctx) require.NoError(t, err) err = stream.Send(&pb.MonitorAlbumStreamRequest{ Message: &pb.MonitorAlbumStreamRequest_Start{ Start: &pb.StartMonitorRequest{ AlbumId: "test-album-ext-id", Quality: pb.QualityType_QUALITY_LOSSLESS, Mode: pb.InteractionMode_INTERACTION_MODE_MANUAL, }, }, }) require.NoError(t, err) _, prompt, err := collectUntilPrompt(t, stream, 0) require.NoError(t, err) require.NotNil(t, prompt) assert.Equal(t, pb.PromptType_PROMPT_TYPE_CONFIRM, prompt.Type) sendDecision(t, stream, prompt.PromptId, &pb.UserDecision{ Decision: &pb.UserDecision_Confirm{Confirm: true}, }) remaining := collectAllMessages(t, stream, 0) var result *pb.MonitorAlbumResponse for _, msg := range remaining { if r := msg.GetResult(); r != nil { result = r break } } require.NotNil(t, result) require.NotNil(t, result.Release) assert.True(t, addMagnetCalled) } func TestMonitorAlbumStream_ManualRejectAdd(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"), ), 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 } var addMagnetCalled bool suite.mocks.torrent.AddMagnetFunc = func(magnetURI string, savePath string) error { addMagnetCalled = true return nil } ctx, cancel := context.WithTimeout(context.Background(), defaultStreamTimeout) defer cancel() stream, err := suite.client.MonitorAlbumStream(ctx) require.NoError(t, err) err = stream.Send(&pb.MonitorAlbumStreamRequest{ Message: &pb.MonitorAlbumStreamRequest_Start{ Start: &pb.StartMonitorRequest{ AlbumId: "test-album-ext-id", Quality: pb.QualityType_QUALITY_LOSSLESS, Mode: pb.InteractionMode_INTERACTION_MODE_MANUAL, }, }, }) require.NoError(t, err) _, prompt, err := collectUntilPrompt(t, stream, 0) require.NoError(t, err) require.NotNil(t, prompt) assert.Equal(t, pb.PromptType_PROMPT_TYPE_CONFIRM, prompt.Type) sendDecision(t, stream, prompt.PromptId, &pb.UserDecision{ Decision: &pb.UserDecision_Confirm{Confirm: false}, }) remaining := collectAllMessages(t, stream, 0) var result *pb.MonitorAlbumResponse for _, msg := range remaining { if r := msg.GetResult(); r != nil { result = r break } } require.NotNil(t, result) assert.Nil(t, result.Release) assert.False(t, addMagnetCalled) bgCtx := context.Background() var downloadCount int err = suite.pool.QueryRow(bgCtx, "SELECT COUNT(*) FROM downloads").Scan(&downloadCount) require.NoError(t, err) assert.Equal(t, 0, downloadCount) } func TestMonitorAlbumStream_ManualAlreadyOwnedContinue(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 } suite.mocks.indexer.SearchFunc = func(query string, limit int32, idx string) (*indexer.SearchResponse, error) { return newSearchResponse( newSearchItem("Owned Album [FLAC 24/96]", 50, "magnet:?xt=urn:btih:newhash"), ), 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 } streamCtx, cancel := context.WithTimeout(context.Background(), defaultStreamTimeout) defer cancel() stream, err := suite.client.MonitorAlbumStream(streamCtx) require.NoError(t, err) err = stream.Send(&pb.MonitorAlbumStreamRequest{ Message: &pb.MonitorAlbumStreamRequest_Start{ Start: &pb.StartMonitorRequest{ AlbumId: "owned-album-ext-id", Quality: pb.QualityType_QUALITY_LOSSLESS, Mode: pb.InteractionMode_INTERACTION_MODE_MANUAL, }, }, }) require.NoError(t, err) messages, prompt, err := collectUntilPrompt(t, stream, 0) require.NoError(t, err) require.NotNil(t, prompt) assert.Equal(t, pb.PromptType_PROMPT_TYPE_CONFIRM, prompt.Type) assertContainsStep(t, messages, pb.MonitorStep_MONITOR_STEP_CHECKING_OWNED) sendDecision(t, stream, prompt.PromptId, &pb.UserDecision{ Decision: &pb.UserDecision_Confirm{Confirm: true}, }) addMessages, addPrompt, err := collectUntilPrompt(t, stream, 0) require.NoError(t, err) require.NotNil(t, addPrompt) assert.Equal(t, pb.PromptType_PROMPT_TYPE_CONFIRM, addPrompt.Type) assertContainsStep(t, addMessages, pb.MonitorStep_MONITOR_STEP_SEARCHING_INDEXER) assertContainsStep(t, addMessages, pb.MonitorStep_MONITOR_STEP_ADDING_TORRENT) sendDecision(t, stream, addPrompt.PromptId, &pb.UserDecision{ Decision: &pb.UserDecision_Confirm{Confirm: true}, }) remaining := collectAllMessages(t, stream, 0) var result *pb.MonitorAlbumResponse for _, msg := range remaining { if r := msg.GetResult(); r != nil { result = r break } } require.NotNil(t, result) } func TestMonitorAlbumStream_FirstMessageMustBeStart(t *testing.T) { suite := setupSuite(t) cleanTables(t, suite.pool) ctx, cancel := context.WithTimeout(context.Background(), defaultStreamTimeout) defer cancel() stream, err := suite.client.MonitorAlbumStream(ctx) require.NoError(t, err) err = stream.Send(&pb.MonitorAlbumStreamRequest{ Message: &pb.MonitorAlbumStreamRequest_Decision{ Decision: &pb.UserDecision{ PromptId: "some-prompt-id", Decision: &pb.UserDecision_Confirm{Confirm: true}, }, }, }) require.NoError(t, err) _, err = stream.Recv() require.Error(t, err) st, ok := status.FromError(err) require.True(t, ok) assert.Equal(t, codes.InvalidArgument, st.Code()) } func TestMonitorAlbumStream_ContextCancellation(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) { select { case <-ctx.Done(): return nil, ctx.Err() case <-time.After(2 * time.Second): return &metadataPb.GetAlbumResponse{ Album: newMetadataAlbum("test-album-ext-id", "Test Album", "artist-ext-id", "Test Artist"), }, nil } } ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) defer cancel() stream, err := suite.client.MonitorAlbumStream(ctx) require.NoError(t, err) err = stream.Send(&pb.MonitorAlbumStreamRequest{ Message: &pb.MonitorAlbumStreamRequest_Start{ Start: &pb.StartMonitorRequest{ AlbumId: "test-album-ext-id", Quality: pb.QualityType_QUALITY_LOSSLESS, Mode: pb.InteractionMode_INTERACTION_MODE_AUTOMATIC, }, }, }) require.NoError(t, err) var lastErr error for { _, err = stream.Recv() if err != nil { lastErr = err break } } require.Error(t, lastErr) st, ok := status.FromError(lastErr) if ok { assert.Contains(t, []codes.Code{codes.Canceled, codes.DeadlineExceeded}, st.Code()) } } func TestMonitorAlbumStream_PromptTimeout(t *testing.T) { suite := setupSuite(t) cleanTables(t, suite.pool) origTimeout := internal.MaxPromptTimeout internal.MaxPromptTimeout = 3 * time.Second t.Cleanup(func() { internal.MaxPromptTimeout = origTimeout }) 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"), ), 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, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() stream, err := suite.client.MonitorAlbumStream(ctx) require.NoError(t, err) err = stream.Send(&pb.MonitorAlbumStreamRequest{ Message: &pb.MonitorAlbumStreamRequest_Start{ Start: &pb.StartMonitorRequest{ AlbumId: "test-album-ext-id", Quality: pb.QualityType_QUALITY_LOSSLESS, Mode: pb.InteractionMode_INTERACTION_MODE_MANUAL, }, }, }) require.NoError(t, err) _, prompt, err := collectUntilPrompt(t, stream, 0) require.NoError(t, err) require.NotNil(t, prompt) remaining := collectAllMessages(t, stream, 15*time.Second) var hasResult bool for _, msg := range remaining { if r := msg.GetResult(); r != nil { hasResult = true break } } assert.True(t, hasResult, "expected workflow to complete with default decision after timeout") } func TestMonitorAlbumStream_InvalidPromptId(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"), ), 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, cancel := context.WithTimeout(context.Background(), defaultStreamTimeout) defer cancel() stream, err := suite.client.MonitorAlbumStream(ctx) require.NoError(t, err) err = stream.Send(&pb.MonitorAlbumStreamRequest{ Message: &pb.MonitorAlbumStreamRequest_Start{ Start: &pb.StartMonitorRequest{ AlbumId: "test-album-ext-id", Quality: pb.QualityType_QUALITY_LOSSLESS, Mode: pb.InteractionMode_INTERACTION_MODE_MANUAL, }, }, }) require.NoError(t, err) _, prompt, err := collectUntilPrompt(t, stream, 0) require.NoError(t, err) require.NotNil(t, prompt) err = stream.Send(&pb.MonitorAlbumStreamRequest{ Message: &pb.MonitorAlbumStreamRequest_Decision{ Decision: &pb.UserDecision{ PromptId: "wrong-prompt-id", Decision: &pb.UserDecision_Confirm{Confirm: true}, }, }, }) require.NoError(t, err) remaining := collectAllMessages(t, stream, 0) var hasError bool for _, msg := range remaining { if errUpdate := msg.GetError(); errUpdate != nil { hasError = true assert.Contains(t, errUpdate.Message, "prompt") break } } assert.True(t, hasError, "expected error for invalid prompt ID") } func TestMonitorAlbumStream_ManualCancelBeforeQBit(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 } indexerCalled := make(chan struct{}) suite.mocks.indexer.SearchFunc = func(query string, limit int32, idx string) (*indexer.SearchResponse, error) { close(indexerCalled) time.Sleep(2 * time.Second) return newSearchResponse( newSearchItem("Test Artist - Test Album [FLAC]", 50, "magnet:?xt=urn:btih:abc123"), ), nil } var addMagnetCalled bool suite.mocks.torrent.AddMagnetFunc = func(magnetURI string, savePath string) error { addMagnetCalled = true return nil } var deleteTorrentCalled bool suite.mocks.torrent.DeleteTorrentFunc = func(hash string) error { deleteTorrentCalled = true return nil } ctx, cancel := context.WithCancel(context.Background()) stream, err := suite.client.MonitorAlbumStream(ctx) require.NoError(t, err) err = stream.Send(&pb.MonitorAlbumStreamRequest{ Message: &pb.MonitorAlbumStreamRequest_Start{ Start: &pb.StartMonitorRequest{ AlbumId: "test-album-ext-id", Quality: pb.QualityType_QUALITY_LOSSLESS, Mode: pb.InteractionMode_INTERACTION_MODE_MANUAL, }, }, }) require.NoError(t, err) <-indexerCalled cancel() for { _, err = stream.Recv() if err != nil { break } } assert.False(t, addMagnetCalled, "AddMagnet should not be called when cancelled before qbit") assert.False(t, deleteTorrentCalled, "DeleteTorrent should not be called when no torrent was added") bgCtx := context.Background() var downloadCount int err = suite.pool.QueryRow(bgCtx, "SELECT COUNT(*) FROM downloads").Scan(&downloadCount) require.NoError(t, err) assert.Equal(t, 0, downloadCount) } func TestMonitorAlbumStream_ManualCancelAfterQBit(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"), ), 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 } torrentAddedCh := make(chan struct{}) suite.mocks.torrent.AddMagnetFunc = func(magnetURI string, savePath string) error { close(torrentAddedCh) return nil } var deletedHash string suite.mocks.torrent.DeleteTorrentFunc = func(hash string) error { deletedHash = hash return nil } ctx, cancel := context.WithCancel(context.Background()) stream, err := suite.client.MonitorAlbumStream(ctx) require.NoError(t, err) err = stream.Send(&pb.MonitorAlbumStreamRequest{ Message: &pb.MonitorAlbumStreamRequest_Start{ Start: &pb.StartMonitorRequest{ AlbumId: "test-album-ext-id", Quality: pb.QualityType_QUALITY_LOSSLESS, Mode: pb.InteractionMode_INTERACTION_MODE_MANUAL, }, }, }) require.NoError(t, err) _, prompt, err := collectUntilPrompt(t, stream, 0) require.NoError(t, err) require.NotNil(t, prompt) assert.Equal(t, pb.PromptType_PROMPT_TYPE_CONFIRM, prompt.Type) sendDecision(t, stream, prompt.PromptId, &pb.UserDecision{ Decision: &pb.UserDecision_Confirm{Confirm: true}, }) select { case <-torrentAddedCh: case <-time.After(5 * time.Second): t.Fatal("timeout waiting for torrent to be added") } cancel() for { _, err = stream.Recv() if err != nil { break } } time.Sleep(100 * time.Millisecond) assert.NotEmpty(t, deletedHash, "DeleteTorrent should be called with the torrent hash") assert.Equal(t, "6ff7af15d0745a3e29d1b9620191cfe01ad3cc70", deletedHash) bgCtx := context.Background() var downloadState string err = suite.pool.QueryRow(bgCtx, "SELECT state FROM downloads WHERE qbit_hash = $1", deletedHash).Scan(&downloadState) if err == nil { assert.Equal(t, "cancelled", downloadState) } } func TestMonitorAlbumStream_AutomaticFireAndForget(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) { time.Sleep(1 * time.Second) return newSearchResponse( newSearchItem("Test Artist - Test 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, cancel := context.WithTimeout(context.Background(), 2*time.Second) stream, err := suite.client.MonitorAlbumStream(ctx) require.NoError(t, err) err = stream.Send(&pb.MonitorAlbumStreamRequest{ Message: &pb.MonitorAlbumStreamRequest_Start{ Start: &pb.StartMonitorRequest{ AlbumId: "test-album-ext-id", Quality: pb.QualityType_QUALITY_LOSSLESS, Mode: pb.InteractionMode_INTERACTION_MODE_AUTOMATIC, }, }, }) require.NoError(t, err) var statusCount int for i := 0; i < 3; i++ { msg, err := stream.Recv() if err != nil { break } if msg.GetStatus() != nil { statusCount++ } } assert.GreaterOrEqual(t, statusCount, 1, "should receive at least one status before disconnect") cancel() time.Sleep(3 * time.Second) bgCtx := context.Background() var downloadCount int err = suite.pool.QueryRow(bgCtx, "SELECT COUNT(*) FROM downloads").Scan(&downloadCount) require.NoError(t, err) assert.Equal(t, 1, downloadCount, "workflow should complete and create download despite client disconnect") } func TestMonitorAlbumStream_AutomaticDuplicateSubscribes(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) { time.Sleep(500 * time.Millisecond) return newSearchResponse( newSearchItem("Test Artist - Test 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 } ctx1, cancel1 := context.WithTimeout(context.Background(), 10*time.Second) defer cancel1() stream1, err := suite.client.MonitorAlbumStream(ctx1) require.NoError(t, err) err = stream1.Send(&pb.MonitorAlbumStreamRequest{ Message: &pb.MonitorAlbumStreamRequest_Start{ Start: &pb.StartMonitorRequest{ AlbumId: "test-album-ext-id", Quality: pb.QualityType_QUALITY_LOSSLESS, Mode: pb.InteractionMode_INTERACTION_MODE_AUTOMATIC, }, }, }) require.NoError(t, err) time.Sleep(100 * time.Millisecond) ctx2, cancel2 := context.WithTimeout(context.Background(), 10*time.Second) defer cancel2() stream2, err := suite.client.MonitorAlbumStream(ctx2) require.NoError(t, err) err = stream2.Send(&pb.MonitorAlbumStreamRequest{ Message: &pb.MonitorAlbumStreamRequest_Start{ Start: &pb.StartMonitorRequest{ AlbumId: "test-album-ext-id", Quality: pb.QualityType_QUALITY_LOSSLESS, Mode: pb.InteractionMode_INTERACTION_MODE_AUTOMATIC, }, }, }) require.NoError(t, err) var stream1Events, stream2Events int done1 := make(chan struct{}) go func() { defer close(done1) for { msg, err := stream1.Recv() if err != nil { return } if msg.GetStatus() != nil || msg.GetResult() != nil || msg.GetError() != nil { stream1Events++ } if msg.GetResult() != nil || msg.GetError() != nil { return } } }() done2 := make(chan struct{}) go func() { defer close(done2) for { msg, err := stream2.Recv() if err != nil { return } if msg.GetStatus() != nil || msg.GetResult() != nil || msg.GetError() != nil { stream2Events++ } if msg.GetResult() != nil || msg.GetError() != nil { return } } }() select { case <-done1: case <-time.After(10 * time.Second): t.Fatal("stream1 timed out") } select { case <-done2: case <-time.After(10 * time.Second): t.Fatal("stream2 timed out") } assert.Greater(t, stream1Events, 0, "stream1 should receive events") assert.Greater(t, stream2Events, 0, "stream2 should receive events") bgCtx := context.Background() var downloadCount int err = suite.pool.QueryRow(bgCtx, "SELECT COUNT(*) FROM downloads").Scan(&downloadCount) require.NoError(t, err) assert.Equal(t, 1, downloadCount, "only one download should be created despite two subscribers") } func TestMonitorAlbumStream_AutomaticReplayOnReconnect(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"), ), 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 } stream1 := startMonitorStream(t, suite.client, &pb.StartMonitorRequest{ AlbumId: "test-album-ext-id", Quality: pb.QualityType_QUALITY_LOSSLESS, Mode: pb.InteractionMode_INTERACTION_MODE_AUTOMATIC, }) messages1 := collectAllMessages(t, stream1, 0) var hasResult1 bool for _, msg := range messages1 { if msg.GetResult() != nil { hasResult1 = true break } } require.True(t, hasResult1, "first workflow should complete") time.Sleep(200 * time.Millisecond) stream2 := startMonitorStream(t, suite.client, &pb.StartMonitorRequest{ AlbumId: "test-album-ext-id", Quality: pb.QualityType_QUALITY_LOSSLESS, Mode: pb.InteractionMode_INTERACTION_MODE_AUTOMATIC, }) messages2 := collectAllMessages(t, stream2, 0) var hasResult2 bool for _, msg := range messages2 { if msg.GetResult() != nil { hasResult2 = true break } } assert.True(t, hasResult2, "new workflow should start and complete after registry cleanup") bgCtx := context.Background() var eventCount int err := suite.pool.QueryRow(bgCtx, "SELECT COUNT(*) FROM album_events").Scan(&eventCount) require.NoError(t, err) assert.Greater(t, eventCount, len(messages1), "should have events from both runs") } func TestMonitorAlbumStream_ManualCancelAfterDownloadSaved(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"), ), 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 } savingCh := make(chan struct{}) suite.mocks.torrent.AddMagnetFunc = func(magnetURI string, savePath string) error { close(savingCh) time.Sleep(2 * time.Second) return nil } var deleteTorrentCalled bool suite.mocks.torrent.DeleteTorrentFunc = func(hash string) error { deleteTorrentCalled = true return nil } ctx, cancel := context.WithCancel(context.Background()) stream, err := suite.client.MonitorAlbumStream(ctx) require.NoError(t, err) err = stream.Send(&pb.MonitorAlbumStreamRequest{ Message: &pb.MonitorAlbumStreamRequest_Start{ Start: &pb.StartMonitorRequest{ AlbumId: "test-album-ext-id", Quality: pb.QualityType_QUALITY_LOSSLESS, Mode: pb.InteractionMode_INTERACTION_MODE_MANUAL, }, }, }) require.NoError(t, err) _, prompt, err := collectUntilPrompt(t, stream, 0) require.NoError(t, err) require.NotNil(t, prompt) sendDecision(t, stream, prompt.PromptId, &pb.UserDecision{ Decision: &pb.UserDecision_Confirm{Confirm: true}, }) select { case <-savingCh: case <-time.After(5 * time.Second): t.Fatal("timeout waiting for saving to start") } cancel() for { _, err = stream.Recv() if err != nil { break } } time.Sleep(3 * time.Second) assert.True(t, deleteTorrentCalled, "DeleteTorrent should be called during cleanup") bgCtx := context.Background() var downloadState string err = suite.pool.QueryRow(bgCtx, "SELECT state FROM downloads WHERE qbit_hash = $1", "6ff7af15d0745a3e29d1b9620191cfe01ad3cc70").Scan(&downloadState) if err == nil { assert.Equal(t, "cancelled", downloadState) } } func TestMonitorAlbumStream_ManualDisconnectCancels(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) { time.Sleep(2 * time.Second) return newSearchResponse( newSearchItem("Test Artist - Test Album [FLAC]", 50, "magnet:?xt=urn:btih:abc123"), ), nil } ctx, cancel := context.WithCancel(context.Background()) stream, err := suite.client.MonitorAlbumStream(ctx) require.NoError(t, err) err = stream.Send(&pb.MonitorAlbumStreamRequest{ Message: &pb.MonitorAlbumStreamRequest_Start{ Start: &pb.StartMonitorRequest{ AlbumId: "test-album-ext-id", Quality: pb.QualityType_QUALITY_LOSSLESS, Mode: pb.InteractionMode_INTERACTION_MODE_MANUAL, }, }, }) require.NoError(t, err) var gotStatus bool for i := 0; i < 3; i++ { msg, err := stream.Recv() if err != nil { break } if msg.GetStatus() != nil { gotStatus = true break } } assert.True(t, gotStatus, "should receive at least one status before disconnect") cancel() _, err = stream.Recv() assert.Error(t, err, "stream.Recv should return error after disconnect") }